diff --git a/.gitignore b/.gitignore index 387265daef..4af0d3c2f9 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,5 @@ packaging/appimage/squashfs-root/ .worktrees/ .claude/worktrees/ benchmark/src/main/jniLibs/ + +/tools/marmot-interop/state 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 0ed223b281..43376d5d6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -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 @@ -189,6 +193,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -334,6 +339,10 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer) + val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings) + val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope) + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -429,6 +438,8 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, + favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator, + favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow, ).flow // App-ready Feeds @@ -1027,6 +1038,12 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm)) + + suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm)) + + fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) @@ -2340,6 +2357,24 @@ class Account( } } + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + if (!isWriteable()) return + val event = bookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return + sendMyPublicAndPrivateOutbox(event) + } + + suspend fun removeDeletedOldBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + if (!isWriteable()) return + val event = oldBookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return + sendMyPublicAndPrivateOutbox(event) + } + /** * Creates a bookmark event without sending it. * Returns the event and target relays for tracked broadcasting. @@ -2438,6 +2473,13 @@ class Account( } } + suspend fun removeDeletedPins(deletedNotes: Set) { + if (!isWriteable()) return + + val event = pinState.removeDeletedPins(deletedNotes) ?: return + sendMyPublicAndPrivateOutbox(event) + } + suspend fun createAddPinEvent(note: Note): Pair>? { if (!isWriteable() || note.isDraft()) return null @@ -2486,7 +2528,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)) @@ -2496,7 +2538,7 @@ class Account( ?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex)) cache.justConsumeMyOwnEvent(request) - onReady(request) + onReady(request, relayList.toSet()) delay(100) client.publish(request, relayList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 21224db39c..5397730e8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -157,6 +158,13 @@ sealed class TopFilter( class Relay( val url: String, ) : TopFilter("Relay/$url") + + @Serializable + class FavoriteAlgoFeed( + val address: Address, + ) : TopFilter("FavoriteAlgoFeed/${address.toValue()}") + + @Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ") } @Stable @@ -199,6 +207,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, + var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -733,6 +742,16 @@ class AccountSettings( } } + fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { + if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) { + backupFavoriteAlgoFeedsList = newFavoriteDvmList + saveAccountSettings() + } + } + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null || newCommunityList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 347df0b52a..aed8edf69a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt new file mode 100644 index 0000000000..0496ad62dd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.algoFeeds + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val RESPONSE_TIMEOUT_MS = 20_000L + +/** + * Immutable snapshot of a favorite algo feed's current request/response state. + * + * - [requestId] is the id of the most recently published kind-5300 request. + * - [responseRelays] is the relay set the kind-5300 was sent to — the same set on + * which the DVM will publish its 6300/7000 responses, so the home subscription + * manager must listen there (not on the user's own outbox). + * - [ids] and [addresses] are the note references returned by the latest kind-6300 response. + * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). + * - [errorMessage] captures any client-side failure while publishing the request. + */ +data class FavoriteAlgoFeedsSnapshot( + val requestId: HexKey? = null, + val responseRelays: Set = emptySet(), + val ids: Set = emptySet(), + val addresses: Set = emptySet(), + val latestStatus: NIP90StatusEvent? = null, + val errorMessage: String? = null, +) + +/** + * Manages the NIP-90 content-discovery RPC cycle for each favorite algo feed the user + * pins to the top-nav. + * + * The orchestrator is lazy: it starts a request/response cycle the first time any + * consumer calls [observe] for a given DVM address, and keeps emitting updated + * snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the + * kind-5300 request (e.g. pull-to-refresh). + * + * This class does not own the relay subscriptions that fetch DVM responses and + * matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the + * user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes + * what the relays deliver into `LocalCache`. + */ +class FavoriteAlgoFeedsOrchestrator( + val account: Account, + val scope: CoroutineScope, +) { + private val flows = mutableMapOf>() + private val jobs = mutableMapOf() + private val mutex = Mutex() + + fun observe(feedAddress: Address): StateFlow { + flows[feedAddress]?.let { return it.asStateFlow() } + + val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot()) + flows[feedAddress] = seed + scope.launch { startFor(feedAddress, seed) } + return seed.asStateFlow() + } + + fun refresh(feedAddress: Address) { + val seed = flows[feedAddress] ?: return + scope.launch { + mutex.withLock { + jobs.remove(feedAddress)?.cancel() + } + startFor(feedAddress, seed) + } + } + + fun stop(feedAddress: Address) { + scope.launch { + mutex.withLock { + jobs.remove(feedAddress)?.cancel() + flows.remove(feedAddress) + } + } + } + + private suspend fun startFor( + feedAddress: Address, + seed: MutableStateFlow, + ) { + val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return + val job = + scope.launch(Dispatchers.IO) { + try { + account.requestDVMContentDiscovery(user) { request, relays -> + seed.update { + it.copy( + requestId = request.id, + responseRelays = relays, + ids = emptySet(), + addresses = emptySet(), + latestStatus = null, + errorMessage = null, + ) + } + } + + val requestId = seed.value.requestId ?: return@launch + + launch { + account.cache + .observeLatestEvent( + Filter( + kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { response -> + if (response == null) return@collectLatest + val (eventIds, addresses) = splitInnerTags(response.innerTags()) + seed.update { + it.copy( + ids = eventIds, + addresses = addresses, + ) + } + } + } + + launch { + account.cache + .observeLatestEvent( + Filter( + kinds = listOf(NIP90StatusEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { status -> + seed.update { it.copy(latestStatus = status) } + } + } + + // If nothing arrives within RESPONSE_TIMEOUT_MS (neither a 6300 + // response nor any 7000 status), surface an error so the banner + // can show Retry instead of spinning forever. + launch { + delay(RESPONSE_TIMEOUT_MS) + val current = seed.value + val stillWaiting = + current.requestId == requestId && + current.ids.isEmpty() && + current.addresses.isEmpty() && + current.latestStatus == null && + current.errorMessage == null + if (stillWaiting) { + seed.update { it.copy(errorMessage = "timeout") } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e) + seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } + } + } + + mutex.withLock { jobs[feedAddress] = job } + } + + private fun splitInnerTags(innerTags: List): Pair, Set> { + val ids = mutableSetOf() + val addresses = mutableSetOf() + innerTags.forEach { value -> + if (value.contains(':')) { + addresses.add(value) + } else if (value.length == 64) { + ids.add(value) + } + } + return ids to addresses + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt index fbe7114088..4521b3deb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -127,4 +127,21 @@ class PinListState( signer = signer, ) } + + suspend fun removeDeletedPins(deletedNotes: Set): PinListEvent? { + val currentList = getPinList() ?: return null + if (deletedNotes.isEmpty()) return null + + val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } + val newTags = + currentList.tags + .filter { tag -> + val bookmark = EventBookmark.parse(tag) + bookmark == null || bookmark.eventId !in deletedIds + }.toTypedArray() + + if (newTags.size == currentList.tags.size) return null + + return PinListEvent.resign(tags = newTags, signer = signer) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt new file mode 100644 index 0000000000..03b3f85367 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet + +class FavoriteAlgoFeedsListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() + + suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt new file mode 100644 index 0000000000..b39d78e80b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class FavoriteAlgoFeedsListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteAlgoFeedsListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress()) + + fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) + + fun getFavoriteAlgoFeedsListFlow(): StateFlow = favoriteAlgoFeedsListNote.flow().metadata.stateFlow + + fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent + + suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList + return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteAlgoFeedsListFlow() + .transformLatest { noteState -> + emit(favoriteAlgoFeedsListWithBackup(noteState.note)) + }.onStart { + emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowNotes: StateFlow> = + flow + .map { addresses -> + addresses.map { cache.getOrCreateAddressableNote(it) } + }.onStart { + emit(flow.value.map { cache.getOrCreateAddressableNote(it) }) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { + val list = getFavoriteAlgoFeedsList() + return if (list == null) { + FavoriteAlgoFeedsListEvent.create(dvm, false, signer) + } else { + FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer) + } + } + + suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { + val list = getFavoriteAlgoFeedsList() ?: return null + return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) + } + + init { + settings.backupFavoriteAlgoFeedsList?.let { event -> + Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" } + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") + getFavoriteAlgoFeedsListFlow().collect { + Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } + (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { + settings.updateFavoriteAlgoFeedsListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 54c03aec62..6c1a5aa944 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image @@ -298,4 +301,55 @@ class LabeledBookmarkListsState( ) account.sendMyPublicAndPrivateOutbox(updatedList) } + + suspend fun removeDeletedBookmarksFromList( + bookmarkListIdentifier: String, + deletedEventIds: Set, + deletedAddresses: Set
, + account: Account, + ) { + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return + + val currentList = getLabeledBookmarkListNote(bookmarkListIdentifier)?.event as? LabeledBookmarkListEvent ?: return + + val newPublicTags = + currentList.tags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() + + val oldPrivateTags = currentList.privateTags(account.signer) + + val updatedList = + if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return + LabeledBookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = account.signer, + ) + } else { + val newPrivateTags = + oldPrivateTags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return + LabeledBookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = account.signer, + ) + } + + account.sendMyPublicAndPrivateOutbox(updatedList) + } } 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 29a169e618..bc523eec88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -30,11 +31,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavoriteAlgoFeedsFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -62,6 +66,8 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, + val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator, + val favoriteAlgoFeedAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -146,6 +152,24 @@ class FeedTopNavFilterState( is TopFilter.Relay -> { RelayFeedFlow(listName.url.normalizeRelayUrl()) } + + is TopFilter.FavoriteAlgoFeed -> { + FavoriteAlgoFeedFlow( + feedAddress = listName.address, + orchestrator = favoriteAlgoFeedsOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } + + TopFilter.AllFavoriteAlgoFeeds -> { + AllFavoriteAlgoFeedsFlow( + favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses, + orchestrator = favoriteAlgoFeedsOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt new file mode 100644 index 0000000000..f239307222 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +/** + * Feed flow that merges snapshots from every currently-favorited DVM into a + * single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the + * favorite set changes. + */ +class AllFavoriteAlgoFeedsFlow( + val favoriteAlgoFeedAddresses: StateFlow>, + val orchestrator: FavoriteAlgoFeedsOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveContentRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun merge( + snapshots: List, + contentRelays: Set, + ): AllFavoriteAlgoFeedsTopNavFilter { + val ids = mutableSetOf() + val addresses = mutableSetOf() + val listen = mutableSetOf() + val requestIds = mutableSetOf() + snapshots.forEach { snap -> + ids += snap.ids + addresses += snap.addresses + listen += snap.responseRelays + snap.requestId?.let { requestIds += it } + } + return AllFavoriteAlgoFeedsTopNavFilter( + acceptedIds = ids, + acceptedAddresses = addresses, + contentRelays = contentRelays, + listenRelays = listen, + requestIds = requestIds, + ) + } + + private fun emptyFilter(contentRelays: Set): AllFavoriteAlgoFeedsTopNavFilter = + AllFavoriteAlgoFeedsTopNavFilter( + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = contentRelays, + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow(): Flow = + favoriteAlgoFeedAddresses.flatMapLatest { addresses -> + if (addresses.isEmpty()) { + combine(outboxRelays, proxyRelays) { outbox, proxy -> + emptyFilter(resolveContentRelays(outbox, proxy)) + } + } else { + val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } + combine(snapshotFlows) { it.toList() } + .let { merged -> + combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy -> + merge(snaps, resolveContentRelays(outbox, proxy)) + } + } + } + } + + override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter { + val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) + val addresses = favoriteAlgoFeedAddresses.value + return if (addresses.isEmpty()) { + emptyFilter(contentRelays) + } else { + merge(addresses.map { orchestrator.observe(it).value }, contentRelays) + } + } + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt new file mode 100644 index 0000000000..eba50b6bcf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Top-nav filter that unions the latest kind-6300 responses from every currently + * favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check + * against a snapshot), but the accepted set is the union across N DVMs and the + * request-id list carries one entry per DVM for the relay-listen subscription. + */ +@Immutable +class AllFavoriteAlgoFeedsTopNavFilter( + val acceptedIds: Set, + val acceptedAddresses: Set, + val contentRelays: Set, + val listenRelays: Set, + val requestIds: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + noteEvent.id in acceptedIds || + (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteAlgoFeedTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = requestIds, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt new file mode 100644 index 0000000000..e17accd43a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class FavoriteAlgoFeedFlow( + val feedAddress: Address, + val orchestrator: FavoriteAlgoFeedsOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun buildFilter( + snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot, + contentRelays: Set, + ) = FavoriteAlgoFeedTopNavFilter( + feedAddress = feedAddress, + acceptedIds = snapshot.ids, + acceptedAddresses = snapshot.addresses, + contentRelays = contentRelays, + listenRelays = snapshot.responseRelays, + requestId = snapshot.requestId, + ) + + override fun flow(): Flow = + combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + buildFilter(snap, resolveRelays(outbox, proxy)) + } + + override fun startValue(): FavoriteAlgoFeedTopNavFilter = + buildFilter( + snapshot = orchestrator.observe(feedAddress).value, + contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), + ) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt new file mode 100644 index 0000000000..6cd70135c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Top-nav filter backed by the latest kind-6300 response from a favorite algo feed. + * + * The filter is a pure immutable membership check: [match] accepts a note only if the + * DVM's latest response included it. When a new response arrives, a new instance is + * emitted through [FavoriteAlgoFeedFlow] and replaces the active filter. + */ +@Immutable +class FavoriteAlgoFeedTopNavFilter( + val feedAddress: Address, + val acceptedIds: Set, + val acceptedAddresses: Set, + val contentRelays: Set, + val listenRelays: Set, + val requestId: HexKey?, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + noteEvent.id in acceptedIds || + (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteAlgoFeedTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = setOfNotNull(requestId), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..374f5ecf0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Immutable +class FavoriteAlgoFeedTopNavPerRelayFilter( + val ids: Set, + val addresses: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..a111c93613 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Two relay sets, two distinct subscriptions: + * + * - [contentFetches] — for each user-configured content relay, the ids/addresses + * we want to pull (the actual notes the DVM curated). + * - [listenRelays] — the union of DVM publish relays across all active DVMs + * (where they will deliver future kind 6300 / 7000 events for their requests). + * - [requestIds] — the set of currently-active kind-5300 request ids to listen + * for. A single feed carries one; the merged "All favorite algo feeds" + * filter carries one per favorite algo feed. + */ +class FavoriteAlgoFeedTopNavPerRelayFilterSet( + val contentFetches: Map, + val listenRelays: Set, + val requestIds: Set, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt new file mode 100644 index 0000000000..48764445e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.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.amethyst.service.okhttp + +import android.os.Build + +internal fun isEmulator(): Boolean = + Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.lowercase().contains("emulator") || + Build.MODEL.contains("google_sdk") || + Build.MODEL.lowercase().contains("droid4x") || + Build.MODEL.contains("Emulator") || + Build.MODEL.contains("Android SDK built for x86") || + Build.MANUFACTURER.contains("Genymotion") || + (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || + "google_sdk" == Build.PRODUCT || + Build.HARDWARE.contains("goldfish") || + Build.HARDWARE.contains("ranchu") || + Build.HARDWARE.contains("vbox86") || + Build.HARDWARE.contains("nox") || + Build.HARDWARE.contains("cuttlefish") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt new file mode 100644 index 0000000000..18875d661a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt @@ -0,0 +1,174 @@ +/* + * 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.okhttp + +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.quartz.utils.Log +import okhttp3.Call +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.EventListener +import okhttp3.Handshake +import okhttp3.Protocol +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * Records phase timings per call so we can see when media loads are slow + * because of DNS, TCP, TLS, first-byte, or because the dispatcher queued + * the call (i.e. we hit maxRequestsPerHost / maxRequests). + * + * Release builds log only slow calls, queued calls, and errors. Debug + * builds log every completed call. + */ +class MediaCallEventListener( + private val dispatcher: Dispatcher, + private val connectionPool: ConnectionPool, +) : EventListener() { + private var callStartNanos = 0L + private var dnsStartNanos = 0L + private var dnsElapsedMs = -1L + private var connectStartNanos = 0L + private var connectElapsedMs = -1L + private var secureStartNanos = 0L + private var secureElapsedMs = -1L + private var responseHeadersNanos = 0L + + // stays true unless connectStart fires (a new connection was needed) + private var connectionReused = true + private var queuedAtStart = 0 + + override fun callStart(call: Call) { + callStartNanos = System.nanoTime() + queuedAtStart = dispatcher.queuedCallsCount() + } + + override fun dnsStart( + call: Call, + domainName: String, + ) { + dnsStartNanos = System.nanoTime() + } + + override fun dnsEnd( + call: Call, + domainName: String, + inetAddressList: List, + ) { + dnsElapsedMs = (System.nanoTime() - dnsStartNanos) / 1_000_000 + } + + override fun connectStart( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + ) { + connectStartNanos = System.nanoTime() + connectionReused = false + } + + override fun connectEnd( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + protocol: Protocol?, + ) { + connectElapsedMs = (System.nanoTime() - connectStartNanos) / 1_000_000 + } + + override fun secureConnectStart(call: Call) { + secureStartNanos = System.nanoTime() + } + + override fun secureConnectEnd( + call: Call, + handshake: Handshake?, + ) { + secureElapsedMs = (System.nanoTime() - secureStartNanos) / 1_000_000 + } + + override fun responseHeadersStart(call: Call) { + responseHeadersNanos = System.nanoTime() + } + + override fun callEnd(call: Call) = finish(call, null) + + override fun callFailed( + call: Call, + ioe: IOException, + ) = finish(call, ioe) + + private fun finish( + call: Call, + error: IOException?, + ) { + val totalMs = (System.nanoTime() - callStartNanos) / 1_000_000 + val isSlow = totalMs >= SLOW_CALL_THRESHOLD_MS + val wasQueued = queuedAtStart > 0 + + if (error == null && !isSlow && !wasQueued && !isDebug) return + + val ttfbMs = if (responseHeadersNanos > 0) (responseHeadersNanos - callStartNanos) / 1_000_000 else -1L + val host = call.request().url.host + val reuseTag = if (connectionReused) "reused" else "new" + + val msg = + buildString { + append(host) + append(" total=").append(totalMs).append("ms") + append(" ttfb=").append(ttfbMs).append("ms") + append(" conn=").append(reuseTag) + if (!connectionReused) { + if (dnsElapsedMs >= 0) append(" dns=").append(dnsElapsedMs).append("ms") + if (connectElapsedMs >= 0) append(" tcp=").append(connectElapsedMs).append("ms") + if (secureElapsedMs >= 0) append(" tls=").append(secureElapsedMs).append("ms") + } + if (wasQueued) { + append(" QUEUED(depth=").append(queuedAtStart) + append(" running=").append(dispatcher.runningCallsCount()) + append(" pool=").append(connectionPool.connectionCount()) + append('/').append(connectionPool.idleConnectionCount()) + append(')') + } + if (error != null) append(" error=").append(error.javaClass.simpleName).append(':').append(error.message) + } + + when { + error != null -> Log.w(TAG, msg) + isSlow || wasQueued -> Log.i(TAG, msg) + else -> Log.d(TAG, msg) + } + } + + companion object { + const val TAG = "MediaHttp" + const val SLOW_CALL_THRESHOLD_MS = 1500L + } +} + +class MediaCallEventListenerFactory( + private val dispatcher: Dispatcher, + private val connectionPool: ConnectionPool, +) : EventListener.Factory { + override fun create(call: Call): EventListener = MediaCallEventListener(dispatcher, connectionPool) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 0dcdbb60b6..61f3255f62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -24,10 +24,13 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS +import okhttp3.ConnectionPool +import okhttp3.Dispatcher import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration +import java.util.concurrent.TimeUnit class OkHttpClientFactory( keyCache: EncryptionKeyCache, @@ -36,9 +39,31 @@ class OkHttpClientFactory( // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) + // Most images/videos in a feed come from a small set of hosts (e.g. a single + // Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests + // per host at 5, which serializes feed loading. Raise the limits so the feed + // can parallelize downloads the way a browser does. + private val dispatcher = + Dispatcher().apply { + if (!isEmulator()) { + maxRequestsPerHost = 16 + maxRequests = 128 + } else { + maxRequestsPerHost = 5 + maxRequests = 64 + } + } + + // Keep more HTTP/2 connections warm so scrolling doesn't repeatedly re-TLS + // to the same media host. + private val connectionPool = ConnectionPool(32, 5, TimeUnit.MINUTES) + private val rootClient = OkHttpClient .Builder() + .dispatcher(dispatcher) + .connectionPool(connectionPool) + .eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool)) .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index 556c91bf07..e4b81b31fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.okhttp -import android.os.Build import com.vitorpamplona.quartz.utils.Log import okhttp3.Dispatcher import okhttp3.OkHttpClient @@ -38,22 +37,6 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 - - private fun isEmulator(): Boolean = - Build.FINGERPRINT.startsWith("generic") || - Build.FINGERPRINT.lowercase().contains("emulator") || - Build.MODEL.contains("google_sdk") || - Build.MODEL.lowercase().contains("droid4x") || - Build.MODEL.contains("Emulator") || - Build.MODEL.contains("Android SDK built for x86") || - Build.MANUFACTURER.contains("Genymotion") || - (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || - "google_sdk" == Build.PRODUCT || - Build.HARDWARE.contains("goldfish") || - Build.HARDWARE.contains("ranchu") || - Build.HARDWARE.contains("vbox86") || - Build.HARDWARE.contains("nox") || - Build.HARDWARE.contains("cuttlefish") } val myDispatcher = 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 5deeb41ee3..db4c9ac9db 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/DeletedItemsBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt new file mode 100644 index 0000000000..13c3e23dc1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import 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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier + +@Composable +fun DeletedItemsBanner( + count: Int, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + if (count <= 0) return + + Column(modifier = StdPadding) { + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + Column(modifier = BigPadding) { + Text( + text = stringRes(R.string.deleted_items_banner_title, count), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = StdVertSpacer) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = stringRes(R.string.deleted_items_banner_dismiss)) + } + Button(onClick = onRemove) { + Text(text = stringRes(R.string.deleted_items_banner_remove)) + } + } + } + } + } +} 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 b99fb85e96..371d21b58d 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 fe147eb19d..133ecdab48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState +import com.vitorpamplona.amethyst.commons.richtext.PdfSegment import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment @@ -480,6 +481,9 @@ private fun RenderWordWithoutPreview( // Don't preview Videos is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText) + // Don't preview PDFs + is PdfSegment -> ClickableUrl(word.segmentText, word.segmentText) + is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) @@ -529,6 +533,7 @@ private fun RenderWordWithPreview( when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 29eb4d8a19..85e57f6d42 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 @@ -20,10 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.components +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.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -32,25 +36,34 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -60,8 +73,10 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip92IMeta.imetas @Composable fun SensitivityWarning( @@ -101,6 +116,52 @@ fun SensitivityWarning( } } +@Composable +fun ContentWarningGate( + isSensitive: Boolean, + reasons: Set, + preloadUrls: List, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.fillMaxWidth(), + backdrop: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + if (!isSensitive) { + content() + return + } + + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + if (showContentWarningNote && preloadUrls.isNotEmpty()) { + val context = LocalContext.current + LaunchedEffect(preloadUrls) { + preloadUrls.forEach { url -> + runCatching { + context.imageLoader.enqueue(ImageRequest.Builder(context).data(url).build()) + } + } + } + } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + if (backdrop != null) { + Box(modifier = modifier.clipToBounds()) { + backdrop() + ContentWarningOverlayBody(reasons) { showContentWarningNote = false } + } + } else { + ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } + } + } else { + content() + } + } +} + @Composable fun ObserveSensitivityWarning( reason: String?, @@ -144,6 +205,135 @@ fun ContentWarningNoteWithBigReasonPreview() { } } +@Composable +fun BlurhashBackdrop( + blurhash: String, + description: String?, +) { + DisplayBlurHash( + blurhash = blurhash, + description = description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +fun BlurhashGridBackdrop(media: List) { + AutoNonlazyGrid(media.size) { idx -> + val item = media[idx] + if (item.blurhash != null) { + DisplayBlurHash( + blurhash = item.blurhash, + description = item.description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +fun mediaSizingModifier( + ratio: Float?, + contentScale: ContentScale, +): Modifier = + when { + contentScale == ContentScale.Crop -> Modifier.fillMaxSize() + ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) + else -> Modifier.fillMaxWidth() + } + +@Composable +private fun ContentWarningOverlayBody( + reasons: Set, + onDismiss: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .height(80.dp) + .width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(70.dp) + .align(Alignment.BottomStart), + tint = Color.White, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(30.dp) + .align(Alignment.TopEnd), + tint = Color.White, + ) + } + + Text( + text = stringRes(R.string.content_warning), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White, + softWrap = true, + textAlign = TextAlign.Center, + ) + + if (reasons.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + reasons.forEach { reason -> + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = reason, + color = Color.White, + ) + }, + colors = + AssistChipDefaults.assistChipColors( + disabledContainerColor = Color.White.copy(alpha = 0.15f), + disabledLabelColor = Color.White, + ), + border = null, + ) + } + } + } + + FilledTonalButton( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + contentPadding = ButtonPadding, + ) { + Text( + text = stringRes(R.string.show_anyway), + ) + } + } + } +} + @Composable fun ContentWarningNote( reason: String?, @@ -221,3 +411,15 @@ fun ContentWarningNote( } } } + +fun collectContentWarningReasons(event: Event): Set { + val reasons = linkedSetOf() + event.contentWarningReason()?.takeIf { it.isNotBlank() }?.let { reasons.add(it) } + event.imetas().forEach { iMeta -> + iMeta.properties[ContentWarningTag.TAG_NAME] + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { reasons.add(it) } + } + return reasons +} 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 c6331c9f0c..e4bed10811 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 08f3ccc1c5..1557661c0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -77,6 +77,8 @@ import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.size.Size import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -86,6 +88,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 +96,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 @@ -150,7 +155,15 @@ fun ZoomableContentView( when (content) { is MediaUrlImage -> { - SensitivityWarning(content.contentWarning, accountViewModel) { + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + ContentWarningGate( + isSensitive = content.contentWarning != null, + reasons = setOfNotNull(content.contentWarning), + preloadUrls = listOf(content.url), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = Modifier @@ -164,7 +177,15 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - SensitivityWarning(content.contentWarning, accountViewModel) { + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + ContentWarningGate( + isSensitive = content.contentWarning != null, + reasons = setOfNotNull(content.contentWarning), + preloadUrls = emptyList(), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { Box( modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), contentAlignment = Alignment.Center, @@ -221,18 +242,36 @@ fun ZoomableContentView( } } } + + is MediaUrlPdf -> { + Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) { + PdfPreviewCard( + content = content, + accountViewModel = accountViewModel, + onOpen = { dialogOpen = true }, + ) + } + } } if (dialogOpen) { - ZoomableImageDialog( - imageUrl = content, - allImages = images, - sourceBounds = sourceBounds, - onDismiss = { - dialogOpen = false - }, - accountViewModel = accountViewModel, - ) + if (content is MediaUrlPdf) { + PdfViewerDialog( + content = content, + accountViewModel = accountViewModel, + onDismiss = { dialogOpen = false }, + ) + } else { + ZoomableImageDialog( + imageUrl = content, + allImages = images, + sourceBounds = sourceBounds, + onDismiss = { + dialogOpen = false + }, + accountViewModel = accountViewModel, + ) + } } } @@ -255,6 +294,7 @@ fun LocalImageView( controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + fullResolution: Boolean = false, ) { if (content.localFileExists()) { val showImage = @@ -265,10 +305,23 @@ fun LocalImageView( } val ratio = remember(content) { content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.localFile.toString()) } + val context = LocalContext.current + val imageModel = + if (fullResolution) { + remember(content.localFile, context) { + ImageRequest + .Builder(context) + .data(content.localFile) + .size(Size.ORIGINAL) + .build() + } + } else { + content.localFile + } CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { imageVisible -> if (imageVisible) { SubcomposeAsyncImage( - model = content.localFile, + model = imageModel, contentDescription = content.description, contentScale = contentScale, modifier = mainImageModifier, @@ -370,6 +423,7 @@ fun UrlImageView( controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + fullResolution: Boolean = false, ) { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) @@ -380,10 +434,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, 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 0000000000..4932b0a97f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.pdf + +import coil3.disk.DiskCache +import com.vitorpamplona.amethyst.Amethyst +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import java.io.IOException + +object PdfFetcher { + /** + * Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is + * responsible for closing the returned snapshot; while it's open the cache entry cannot be + * evicted, so the underlying file stays valid for `PdfRenderer`. + * + * Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU + * eviction and disk budget as images. + */ + suspend fun fetchSnapshot( + url: String, + okHttpClient: (String) -> OkHttpClient, + ): DiskCache.Snapshot { + val diskCache = Amethyst.instance.diskCache + diskCache.openSnapshot(url)?.let { return it } + + return withContext(Dispatchers.IO) { + val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url") + try { + val request = + Request + .Builder() + .url(url) + .get() + .build() + + okHttpClient(url).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw IOException("PDF download failed: ${response.code}") + } + diskCache.fileSystem.write(editor.data) { + val bytes = writeAll(response.body.source()) + if (bytes == 0L) throw IOException("PDF download failed: empty response body") + } + } + + editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url") + } catch (t: Throwable) { + runCatching { editor.abort() } + throw t + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt new file mode 100644 index 0000000000..0712e95d14 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.pdf + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PictureAsPdf +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages. +private const val THUMBNAIL_MAX_DIM_PX = 1600 + +data class PdfPreview( + val thumbnail: Bitmap, + val pageCount: Int, + val aspectRatio: Float, +) + +private sealed class PdfLoadState { + data object Loading : PdfLoadState() + + data class Ready( + val preview: PdfPreview, + ) : PdfLoadState() + + data object Failed : PdfLoadState() +} + +@Composable +fun PdfPreviewCard( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, +) { + val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) } + + if (showPdf.value) { + LoadedPdfPreviewCard(content, accountViewModel, onOpen) + } else { + PlaceholderPdfCard(content) { showPdf.value = true } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LoadedPdfPreviewCard( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, +) { + val sharePopupExpanded = remember { mutableStateOf(false) } + + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val targetWidthPx = + remember(density, configuration) { + val screenPx = + with(density) { + configuration.screenWidthDp.dp + .toPx() + .toInt() + } + screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1) + } + + @Suppress("ProduceStateDoesNotAssignValue") + val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) { + value = + try { + PdfFetcher + .fetchSnapshot(content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + }.use { snapshot -> + withContext(Dispatchers.IO) { + renderFirstPage(snapshot.data.toFile(), targetWidthPx) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e) + PdfLoadState.Failed + } + } + + ShareMediaAction( + accountViewModel = accountViewModel, + popupExpanded = sharePopupExpanded, + content = content, + onDismiss = { sharePopupExpanded.value = false }, + ) + + val filename = remember(content.url) { extractFilename(content.url) } + + when (val current = state) { + is PdfLoadState.Loading -> { + PdfSkeletonCard(filename) + } + + is PdfLoadState.Failed -> { + ClickableUrl(urlText = content.url, url = content.url) + } + + is PdfLoadState.Ready -> { + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .combinedClickable( + onClick = onOpen, + onLongClick = { sharePopupExpanded.value = true }, + ), + ) { + Image( + bitmap = current.preview.thumbnail.asImageBitmap(), + contentDescription = content.description ?: filename, + contentScale = ContentScale.FillWidth, + filterQuality = FilterQuality.High, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)), + ) + + FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount)) + + Spacer(modifier = DoubleVertSpacer) + } + } + } +} + +@Composable +private fun PlaceholderPdfCard( + content: MediaUrlPdf, + onLoad: () -> Unit, +) { + val filename = remember(content.url) { extractFilename(content.url) } + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .fillMaxWidth() + .combinedClickable(onClick = onLoad, onLongClick = onLoad), + ) { + FilenameRow(filename = filename, subtitle = "Tap to load PDF") + Spacer(modifier = DoubleVertSpacer) + } +} + +@Composable +private fun PdfSkeletonCard(filename: String) { + Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) { + FilenameRow(filename = filename, subtitle = "Loading…") + Spacer(modifier = DoubleVertSpacer) + } +} + +@Composable +private fun FilenameRow( + filename: String, + subtitle: String, +) { + Row( + modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.PictureAsPdf, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = filename, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +private fun renderFirstPage( + file: java.io.File, + targetWidthPx: Int, +): PdfLoadState = + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + val pageCount = renderer.pageCount + if (pageCount <= 0) return@use PdfLoadState.Failed + + renderer.openPage(0).use { page -> + val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) + // PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output. + val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + PdfLoadState.Ready( + PdfPreview( + thumbnail = bitmap, + pageCount = pageCount, + aspectRatio = page.width.toFloat() / page.height.toFloat(), + ), + ) + } + } + } + +/** + * Returns the bitmap dimensions to render a PDF page at, scaled so the longest side equals + * [targetDim] while preserving aspect ratio. Always scales, never returns native size: a PDF + * page's native width/height are in PostScript points (1/72"), which is far below any useful + * display resolution. Since PDFs are vector, rendering at a larger target is essentially free + * and avoids a 72-DPI-blurry bitmap. + */ +internal fun cappedRenderSize( + pageWidth: Int, + pageHeight: Int, + targetDim: Int, +): Pair { + if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1 + val longest = maxOf(pageWidth, pageHeight) + val scale = targetDim.toFloat() / longest + val w = (pageWidth * scale).toInt().coerceAtLeast(1) + val h = (pageHeight * scale).toInt().coerceAtLeast(1) + return w to h +} + +internal fun extractFilename(url: String): String { + val afterQuery = url.substringBefore('?').substringBefore('#') + val name = afterQuery.substringAfterLast('/', afterQuery) + return if (name.isBlank()) url else name +} + +internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt new file mode 100644 index 0000000000..57279441c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -0,0 +1,415 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.pdf + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.disk.DiskCache +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.toggleScale +import net.engawapg.lib.zoomable.zoomable + +// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes +// of RAM (PdfRenderer requires ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +private const val VIEWER_MAX_DIM_PX = 3072 + +// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in +// past HI_RES_ZOOM_THRESHOLD we re-render the current page at +// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within +// common GPU texture limits (so drawing stays hardware-accelerated) and caps +// memory at ~48 MB for an A4-shaped page. Above 4096 most mid-range GPUs fall +// back to software rendering, which is what caused the pan/zoom jitter. +private const val HI_RES_MAX_DIM_PX = 4096 +private const val HI_RES_ZOOM_THRESHOLD = 1.5f +private const val HI_RES_DEBOUNCE_MS = 200L + +// Zoom level the viewer animates to when the user double-taps. Matches the +// threshold region where we swap in the hi-res bitmap. +private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f + +// How many recently-rendered pages to keep around. Pager already pre-composes the +// current page plus one neighbor; this just speeds up small back/forward swipes. +// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps. +private const val PAGE_CACHE_SIZE = 3 + +private class PageBitmapCache( + private val maxSize: Int, +) { + private val cache = + object : java.util.LinkedHashMap(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > maxSize + } + + @Synchronized fun get(key: Int): Bitmap? = cache[key] + + @Synchronized fun put( + key: Int, + value: Bitmap, + ) { + cache[key] = value + } +} + +private class PdfDocumentHandle( + val snapshot: DiskCache.Snapshot, + val pfd: ParcelFileDescriptor, + val renderer: PdfRenderer, +) { + // Snapshot eagerly. Compose's saveable PagerState reads pageCount during + // teardown, which can run *after* close() — so we can't query the renderer + // lazily without tripping IllegalStateException("Document already closed"). + val pageCount: Int = renderer.pageCount + val mutex: Mutex = Mutex() + + @Volatile var closed: Boolean = false + private set + + fun close() { + closed = true + runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) } + runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) } + runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) } + } +} + +@Composable +fun PdfViewerDialog( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) { + PdfViewerContent( + content = content, + accountViewModel = accountViewModel, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun PdfViewerContent( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + @Suppress("ProduceStateDoesNotAssignValue") + val handleState by produceState(initialValue = null, key1 = content.url) { + value = + try { + withContext(Dispatchers.IO) { + val snapshot = + PdfFetcher.fetchSnapshot(content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + try { + val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(pfd) + PdfDocumentHandle(snapshot, pfd, renderer) + } catch (t: Throwable) { + runCatching { snapshot.close() } + throw t + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e) + null + } + } + + // Capture the handle as a local val so the onDispose lambda closes *this* handle, + // not whatever the delegated property reads at dispose time. Without this, the + // DisposableEffect keyed on handleState runs its onDispose when handleState + // transitions from null -> handle, and `handleState?.close()` reads the new handle + // and closes it right after it was created. + val handleForDispose = handleState + DisposableEffect(handleForDispose) { + onDispose { + handleForDispose?.close() + } + } + + val sharePopupExpanded = remember { mutableStateOf(false) } + + ShareMediaAction( + accountViewModel = accountViewModel, + popupExpanded = sharePopupExpanded, + content = content, + onDismiss = { sharePopupExpanded.value = false }, + ) + + val handle = handleState + if (handle == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color.White) + } + } else if (handle.pageCount == 0) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Unable to open PDF", + color = Color.White, + ) + } + } else { + val pagerState = rememberPagerState { handle.pageCount } + val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) } + + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + PdfPageView( + handle = handle, + pageIndex = pageIndex, + cache = pageCache, + ) + } + + Row( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .statusBarsPadding() + .systemBarsPadding() + .padding(horizontal = Size15dp, vertical = Size10dp), + horizontalArrangement = spacedBy(Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = "${pagerState.currentPage + 1} / ${handle.pageCount}", + color = Color.White, + modifier = + Modifier + .background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small) + .padding(horizontal = Size10dp, vertical = Size5dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + OutlinedButton( + onClick = { sharePopupExpanded.value = true }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringRes(R.string.quick_action_share), + ) + } + } + } + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun PdfPageView( + handle: PdfDocumentHandle, + pageIndex: Int, + cache: PageBitmapCache, +) { + val cached = cache.get(pageIndex) + + @Suppress("ProduceStateDoesNotAssignValue") + val baseBitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { + if (value != null) return@produceState + val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX) + rendered?.let { cache.put(pageIndex, it) } + value = rendered + } + + val zoomState = rememberZoomState() + + // Re-render the page at a higher resolution once the user zooms in and settles, + // so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base + // bitmap. Released when zoom drops back under threshold or the page leaves view. + var hiResBitmap by remember(handle, pageIndex) { mutableStateOf(null) } + + LaunchedEffect(handle, pageIndex, baseBitmap) { + if (baseBitmap == null) return@LaunchedEffect + snapshotFlow { zoomState.scale } + .debounce(HI_RES_DEBOUNCE_MS) + .distinctUntilChanged() + .collectLatest { scale -> + if (scale < HI_RES_ZOOM_THRESHOLD) { + hiResBitmap = null + } else { + val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX) + // Skip if the hi-res render wouldn't beat what we already have. + val base = baseBitmap ?: return@collectLatest + val baseLongest = maxOf(base.width, base.height) + if (target <= baseLongest) { + hiResBitmap = null + } else { + hiResBitmap = renderPageCatching(handle, pageIndex, target) + } + } + } + } + + val current = hiResBitmap ?: baseBitmap + // Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new + // one and push Compose into thinking the texture changed. + val imageBitmap = remember(current) { current?.asImageBitmap() } + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.Fit, + // Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every + // frame during pan/zoom, which is the main source of jitter when the + // source bitmap is several megapixels. Bilinear on a 3072-4096 px source + // looks effectively identical on-screen. + filterQuality = FilterQuality.Medium, + modifier = + Modifier + .fillMaxSize() + .zoomable( + zoomState = zoomState, + onDoubleTap = { position -> + zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position) + }, + ), + ) + } else { + CircularProgressIndicator(color = Color.White) + } + } +} + +private suspend fun renderPageCatching( + handle: PdfDocumentHandle, + pageIndex: Int, + maxDim: Int, +): Bitmap? = + try { + handle.mutex.withLock { + if (handle.closed) { + null + } else { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val (width, height) = cappedRenderSize(page.width, page.height, maxDim) + // PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently + // rejected and produces blank output. + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.WHITE) + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bmp + } + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e) + null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f7a0691252..70461364db 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 @@ -98,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -283,6 +284,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b9d6ba5099..5e33e00531 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 @@ -187,6 +187,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object EditFavoriteAlgoFeeds : Route() + @Serializable object EditPaymentTargets : Route() @Serializable object UpdateReactionType : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c3f7bba7ca..9d01c8a124 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Person @@ -67,6 +68,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName +import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -150,23 +154,40 @@ fun FeedFilterSpinner( Row(verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Size20Modifier) - Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Bound the Column so long filter names (e.g. DVM titles) get truncated + // instead of wrapping to multiple lines and shoving the expand icon out. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f, fill = false), + ) { val filter = selected?.code if (filter is TopFilter.Geohash) { LoadCityName( geohashStr = filter.tag, onLoading = { Row { - Text(filter.tag) + Text( + text = filter.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Spacer(modifier = StdHorzSpacer) LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) } }, ) { cityName -> - Text(cityName) + Text( + text = cityName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } else { - Text(currentText) + Text( + text = currentText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } if (filter is TopFilter.AroundMe) { @@ -178,6 +199,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } else { val location by Amethyst.instance.locationManager.geohashStateFlow @@ -193,6 +216,8 @@ fun FeedFilterSpinner( text = "(${myLocation.geoHash})", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = StdHorzSpacer) LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) @@ -203,6 +228,8 @@ fun FeedFilterSpinner( text = "($cityName)", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -212,6 +239,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -220,6 +249,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -329,6 +360,20 @@ fun RenderOption( color = MaterialTheme.colorScheme.onSurface, ) } + + is FavoriteAlgoFeedName -> { + val noteState by observeNote(option.note, accountViewModel) + val name = + (noteState.note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } ?: option.note.dTag() + Text( + text = name, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.onSurface, + ) + } } } @@ -346,6 +391,7 @@ private enum class FeedGroup( COMMUNITIES(R.string.feed_group_communities), LOCATIONS(R.string.feed_group_locations), LISTS(R.string.feed_group_lists), + DVMS(R.string.feed_group_dvms), RELAYS(R.string.feed_group_relays), } @@ -373,10 +419,15 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + FeedGroup.DVMS + } + is ResourceName -> { when (entry.item.code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -541,12 +592,21 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } + is TopFilter.FavoriteAlgoFeed -> { + Icons.Outlined.AutoAwesome + } + + is TopFilter.AllFavoriteAlgoFeeds -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList + is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 825a9d9f42..6730e72c9e 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 @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,14 +39,20 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -59,7 +67,9 @@ fun PictureDisplay( nav: INav, ) { val event = (note.event as? PictureEvent) ?: return - val uri = note.toNostrUri() + val uri = remember(note) { note.toNostrUri() } + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } val images by remember(note) { @@ -85,29 +95,46 @@ fun PictureDisplay( if (first != null) { val title = event.title() - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column { - if (title != null) { - Text( - modifier = Modifier.padding(padding), - text = title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(StdVertSpacer) - } + Column { + if (title != null) { + Text( + modifier = Modifier.padding(padding), + text = title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(StdVertSpacer) + } - if (images.size == 1) { + if (images.size == 1) { + val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = listOf(first.url), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } }, + ) { ZoomableContentView( - content = images.first(), + content = first, images = images, roundedCorner = roundedCorner, contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) - } else { + } + } else { + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = images.map { it.url }, + accountViewModel = accountViewModel, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + backdrop = { BlurhashGridBackdrop(images) }, + ) { AutoNonlazyGrid(images.size) { ZoomableContentView( content = images[it], @@ -118,20 +145,20 @@ fun PictureDisplay( ) } } - - TranslatableRichTextViewer( - content = event.content, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.padding(padding), - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = uri, - accountViewModel = accountViewModel, - nav = nav, - ) } + + TranslatableRichTextViewer( + content = event.content, + canPreview = false, + quotesLeft = 0, + modifier = Modifier.padding(padding), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = uri, + accountViewModel = accountViewModel, + nav = nav, + ) } } } 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 581ef978bd..63cb577ebe 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 @@ -29,12 +29,17 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent @Composable @@ -48,11 +53,13 @@ fun JustVideoDisplay( val event = (videoEvent as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } + val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } val content by remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) mutableStateOf( if (isImage) { @@ -80,7 +87,16 @@ fun JustVideoDisplay( ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { ZoomableContentView( content = content, roundedCorner = roundedCorner, 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 d8a0c7cfb4..fd2252b3fa 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 @@ -100,6 +101,12 @@ class TopNavFilterState( FeedDefinition( code = TopFilter.Mine, name = ResourceName(R.string.follow_list_mine), + ) + + val allFavoriteAlgoFeedsFollow = + FeedDefinition( + code = TopFilter.AllFavoriteAlgoFeeds, + name = ResourceName(R.string.follow_list_all_favorite_dvms), ) val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) @@ -146,6 +153,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, + favoriteAlgoFeedsList: List, ): List { val hashtags = hashtagList.map { @@ -179,7 +187,26 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays).sortedBy { it.name.name() } + // Favorites can only be added through FavoriteAlgoFeedToggle, which itself checks + // that the AppDefinitionEvent advertises kind 5300. Don't re-check here: on + // cold start the AppDefinitionEvent may not be in cache yet, and dropping + // the entry means the persisted TopFilter.FavoriteAlgoFeed can't find its chip + // in the spinner (user sees "Select an option" while the banner fires the + // RPC — the bug we had before this change). + val favoriteAlgoFeeds = + favoriteAlgoFeedsList.map { feedNote -> + FeedDefinition( + TopFilter.FavoriteAlgoFeed(feedNote.address), + FavoriteAlgoFeedName(feedNote), + ) + } + + // Only show the "All favorite algo feeds" meta-chip when there is at least one + // real favorite to merge; otherwise the chip opens to an empty feed. + val allFavorites = + if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList() + + return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -189,6 +216,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, + account.favoriteAlgoFeedsList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -197,6 +225,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, + account.favoriteAlgoFeedsList.flowNotes.value, ), ) } @@ -321,6 +350,15 @@ class CommunityName( override fun name() = "/n/${(note.dTag())}" } +@Stable +class FavoriteAlgoFeedName( + val note: AddressableNote, +) : Name() { + override fun name(): String = + (note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() } + ?: note.dTag() +} + @Immutable class FeedDefinition( val code: TopFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 56d1640939..3096c83116 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 @@ -919,6 +920,10 @@ class AccountViewModel( } } + fun removeDeletedPins(deletedNotes: Set) { + launchSigner { account.removeDeletedPins(deletedNotes) } + } + fun addPrivateBookmark(note: Note) { if (settings.isCompleteUIMode()) { launchSigner { @@ -988,6 +993,20 @@ class AccountViewModel( } } + fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + launchSigner { account.removeDeletedBookmarks(deletedEventIds, deletedAddresses) } + } + + fun removeDeletedOldBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + launchSigner { account.removeDeletedOldBookmarks(deletedEventIds, deletedAddresses) } + } + fun broadcast(note: Note) = launchSigner { account.broadcast(note) } fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } @@ -1088,6 +1107,12 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) } + + fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) } + + fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm) + fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } @@ -1802,8 +1827,8 @@ class AccountViewModel( onReady: (event: Note) -> Unit, ) { launchSigner { - account.requestDVMContentDiscovery(dvmPublicKey) { - onReady(LocalCache.getOrCreateNote(it.id)) + account.requestDVMContentDiscovery(dvmPublicKey) { request, _ -> + onReady(LocalCache.getOrCreateNote(request.id)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index 7ee7752c32..f32fd36849 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -34,14 +34,18 @@ 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.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -81,7 +85,7 @@ fun BookmarkListScreen( // Preload all bookmarked events so they don't load one-by-one when scrolling PreloadBookmarkEvents(bookmarkState, accountViewModel) - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @Composable @@ -89,12 +93,36 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() + val cache = accountViewModel.account.cache + val deletedEventIds = remember(bookmarkState) { mutableSetOf() } + val deletedAddresses = remember(bookmarkState) { mutableSetOf() } + val deletedCount = + remember(bookmarkState) { + deletedEventIds.clear() + deletedAddresses.clear() + val all = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + all.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -122,6 +150,19 @@ private fun RenderBookmarkScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 18795d5e87..6ef2e1fd2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -44,10 +44,12 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow @@ -56,7 +58,9 @@ 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.model.AddressableNote import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection @@ -70,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip01Core.core.Address import kotlinx.coroutines.launch @Composable @@ -158,6 +163,11 @@ fun BookmarkGroupScreenView( ).consumeWindowInsets(padding) .imePadding(), ) { + DeletedBookmarksBanner( + bookmarkGroupViewModel = bookmarkGroupViewModel, + bookmarkType = bookmarkType, + accountViewModel = accountViewModel, + ) when (bookmarkType) { BookmarkType.PostBookmark -> { RenderPostList( @@ -217,6 +227,67 @@ fun BookmarkGroupScreenView( } } +@Composable +private fun DeletedBookmarksBanner( + bookmarkGroupViewModel: BookmarkGroupViewModel, + bookmarkType: BookmarkType, + accountViewModel: AccountViewModel, +) { + val postsFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.publicPosts() } + val privatePostsFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.privatePosts() } + val articlesFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.publicArticles() } + val privateArticlesFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.privateArticles() } + + val publicPosts by postsFlow.collectAsStateWithLifecycle() + val privatePosts by privatePostsFlow.collectAsStateWithLifecycle() + val publicArticles by articlesFlow.collectAsStateWithLifecycle() + val privateArticles by privateArticlesFlow.collectAsStateWithLifecycle() + + val cache = accountViewModel.account.cache + + val deletedEventIds = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf() } + val deletedAddresses = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf
() } + val deletedCount = + remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { + deletedEventIds.clear() + deletedAddresses.clear() + val scope = + when (bookmarkType) { + BookmarkType.PostBookmark -> publicPosts + privatePosts + BookmarkType.ArticleBookmark -> publicArticles + privateArticles + } + scope.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember(bookmarkType) { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.launchSigner { + bookmarkGroupViewModel.removeDeletedBookmarksFromGroup( + deletedEventIds = deletedEventIds.toSet(), + deletedAddresses = deletedAddresses.toSet(), + ) + } + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } +} + @Composable private fun TitleAndDescription(viewModel: BookmarkGroupViewModel) { val selectedSetState = viewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index d58040c705..9cba1b5ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -155,6 +155,19 @@ class BookmarkGroupViewModel( ) } + suspend fun removeDeletedBookmarksFromGroup( + groupIdentifier: String = bookmarkGroupIdentifier, + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + account.labeledBookmarkLists.removeDeletedBookmarksFromList( + groupIdentifier, + deletedEventIds, + deletedAddresses, + account, + ) + } + @Suppress("UNCHECKED_CAST") class Initializer( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt index 3e0b8176a3..e4d281a92f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt @@ -40,15 +40,19 @@ 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.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -88,7 +92,7 @@ fun OldBookmarkListScreen( // Preload all bookmarked events so they don't load one-by-one when scrolling PreloadOldBookmarkEvents(bookmarkState, accountViewModel) - RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @SuppressLint("LocalContextGetResourceValueCall") @@ -97,6 +101,7 @@ fun OldBookmarkListScreen( private fun RenderOldBookmarkScreen( publicFeedViewModel: OldBookmarkPublicFeedViewModel, privateFeedViewModel: OldBookmarkPrivateFeedViewModel, + bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { @@ -104,6 +109,29 @@ private fun RenderOldBookmarkScreen( val coroutineScope = rememberCoroutineScope() val context = LocalContext.current + val cache = accountViewModel.account.cache + val deletedEventIds = remember(bookmarkState) { mutableSetOf() } + val deletedAddresses = remember(bookmarkState) { mutableSetOf() } + val deletedCount = + remember(bookmarkState) { + deletedEventIds.clear() + deletedAddresses.clear() + val all = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + all.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -156,6 +184,19 @@ private fun RenderOldBookmarkScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedOldBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> { 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 6fa904415a..329444e2f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -21,11 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -51,12 +49,12 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteAlgoFeedToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.nip05 @@ -118,25 +116,12 @@ fun RenderContentDVMThumb( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = RowColSpacing5dp, - ) { - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, + if (baseNote is AddressableNote) { + FavoriteAlgoFeedToggle( + appDefinitionNote = baseNote, accountViewModel = accountViewModel, - nav, ) } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) }, onDescription = { card.description?.let { @@ -168,22 +153,16 @@ fun RenderContentDVMThumb( color = MaterialTheme.colorScheme.primary amount = card.amount + " Sats" } - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $amount ", - color = color, - maxLines = 3, - modifier = - Modifier - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $amount ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) } Spacer(modifier = StdHorzSpacer) card.personalized?.let { @@ -196,24 +175,35 @@ fun RenderContentDVMThumb( color = MaterialTheme.colorScheme.nip05 name = "Generic" } - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $name ", - color = color, - maxLines = 3, - modifier = - Modifier - .padding(start = 4.dp) - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $name ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing5dp, + ) { + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 0d81a4cc8f..3f5dea38e2 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 928a2d9a8d..78e65eed30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -21,19 +21,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @Composable fun DvmTopBar( @@ -41,7 +47,7 @@ fun DvmTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - TopBarExtensibleWithBackButton( + MyExtensibleTopAppBar( title = { LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> if (appDefinitionNote != null) { @@ -82,6 +88,27 @@ fun DvmTopBar( } } }, - popBack = nav::popBack, + navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } }, + actions = { + // The route passes the event's hex id, so LoadNote returns a plain Note, + // not the AddressableNote the toggle needs. Derive the AddressableNote + // from the loaded AppDefinitionEvent's address() once the event exists. + LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> + if (appDefinitionNote != null) { + val addressableNote by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.let { + LocalCache.getOrCreateAddressableNote(it.address()) + } + } + addressableNote?.let { target -> + FavoriteAlgoFeedToggle( + appDefinitionNote = target, + accountViewModel = accountViewModel, + ) + } + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt new file mode 100644 index 0000000000..3838bef642 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent + +/** + * Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM. + * + * Uses [ClickableBox] (no [androidx.compose.material3.IconButton] padding) so it + * fits in a `LeftPictureLayout` title row alongside other compact reactions + * without inflating the row to 48dp. + * + * Hidden until the underlying [AppDefinitionEvent] loads and we can confirm the + * DVM advertises kind 5300 — favouriting any other DVM type would only stall on + * a 6300 reply that never comes. + */ +@Composable +fun FavoriteAlgoFeedToggle( + appDefinitionNote: AddressableNote, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + iconSizeModifier: Modifier = Size20Modifier, +) { + val supportsContentDiscovery by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + } + + if (!supportsContentDiscovery) return + + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow + .collectAsStateWithLifecycle() + + val isFavorite = favorites.contains(appDefinitionNote.address) + + ClickableBox( + modifier = modifier, + onClick = { + if (isFavorite) { + accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address) + } else { + accountViewModel.followFavoriteAlgoFeed( + AddressBookmark( + address = appDefinitionNote.address, + relayHint = appDefinitionNote.relayHintUrl(), + ), + ) + } + }, + ) { + if (isFavorite) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + modifier = iconSizeModifier, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.StarBorder, + contentDescription = stringRes(R.string.add_dvm_to_favorites), + modifier = iconSizeModifier, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt new file mode 100644 index 0000000000..8d71292bc1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoriteAlgoFeedsListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.favorite_dvms_title), + popBack = nav::popBack, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding), + ) { + Text( + text = stringRes(R.string.favorite_dvms_explainer), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + + FavoriteAlgoFeedList(accountViewModel, nav) + } + } +} + +@Composable +private fun FavoriteAlgoFeedList( + accountViewModel: AccountViewModel, + nav: INav, +) { + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes + .collectAsStateWithLifecycle() + + if (favorites.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringRes(R.string.favorite_dvms_empty), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + return + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = FeedPadding, + ) { + items( + items = favorites, + key = { it.address.toValue() }, + ) { feedNote -> + FavoriteAlgoFeedRow( + feedNote = feedNote, + accountViewModel = accountViewModel, + onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) }, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FavoriteAlgoFeedRow( + feedNote: AddressableNote, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, + onRemove: () -> Unit, +) { + val card = observeAppDefinition(feedNote, accountViewModel) + + Row( + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = onOpen) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + card.cover?.let { cover -> + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = cover, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage35Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + feedNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + onError = { + feedNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + feedNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = card.name.ifBlank { feedNote.dTag() }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + card.description?.takeIf { it.isNotBlank() }?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.grayText, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt new file mode 100644 index 0000000000..22d10c444b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +@Composable +fun HomeAlgoFeedStatusBanner( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val topFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + when (val filter = topFilter) { + is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier) + else -> Unit + } +} + +@Composable +private fun SingleAlgoFeedBanner( + favFeed: TopFilter.FavoriteAlgoFeed, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator + .observe(favFeed.address) + .collectAsStateWithLifecycle() + + // Hide the banner when the feed is already populated. + if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return + + val feedAddressValue = favFeed.address.toValue() + + LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote -> + val resolvedName by + observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } + ?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag() + ?: "" + } + + BannerCard(modifier) { + val status = snapshot.latestStatus?.status() + + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } + } + + status?.code == "payment-required" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_payment_required) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + var statusOverride by remember { mutableStateOf(null) } + val msg = statusOverride + if (msg != null) { + Text(text = msg, style = MaterialTheme.typography.bodySmall) + Spacer(modifier = StdVertSpacer) + } + snapshot.latestStatus?.let { + DvmPaymentActions( + latestStatus = it, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { statusOverride = it }, + ) + } + } + + status?.code == "error" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_error) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } + } + + status?.code == "processing" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_processing) + }, + showSpinner = true, + ) + } + + else -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting, resolvedName), + showSpinner = true, + ) + } + } + } + } +} + +@Composable +private fun AllFavoriteAlgoFeedsBanner( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow + .collectAsStateWithLifecycle() + + if (addresses.isEmpty()) return + + // Observe each DVM's snapshot so we can decide whether to hide the banner + // based on the aggregate state. Hide it as soon as any DVM has produced a + // feed; only error out when every one of them has errored. + val snapshots: List = + addresses.map { address -> + val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator + .observe(address) + .collectAsStateWithLifecycle() + snap + } + + val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() } + if (anyResponded) return + + val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" } + + BannerCard(modifier) { + if (allErrored) { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { + addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } + } + } else { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting_all), + showSpinner = true, + ) + } + } +} + +@Composable +private fun BannerCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + // Sits above the feed instead of in the topBar Column, so adding/removing + // the banner doesn't shift the tabs/filter up and down. tonalElevation + // gives it a faint surface tint so it reads as a floating overlay. + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { content() } + } +} + +@Composable +private fun BannerMessageRow( + message: String, + showSpinner: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + if (showSpinner) { + LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp) + Spacer(modifier = StdHorzSpacer) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun RetryButton(onClick: () -> Unit) { + OutlinedButton(onClick = onClick) { + Text(stringRes(R.string.dvm_home_retry)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 77adf8355f..fb2ee89146 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow @@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -209,24 +213,37 @@ private fun HomePages( HomeScreenFloatingButton(accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { - HorizontalPager( - contentPadding = it, - state = pagerState, - userScrollEnabled = true, - modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), - ) { page -> - HomeFeeds( - feedState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - scrollStateKey = tabs[page].scrollStateKey, - liveSection = tabs[page].liveSection, + ) { paddingValues -> + // Wrap pager + banner in a Box so the banner can float over the feed + // (anchored top-center) instead of living in the topBar Column where + // it would push the tabs down every time it appears or disappears. + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) { + HorizontalPager( + contentPadding = PaddingValues(0.dp), + state = pagerState, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), + ) { page -> + HomeFeeds( + feedState = tabs[page].feedState, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + liveSection = tabs[page].liveSection, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HomeAlgoFeedStatusBanner( accountViewModel = accountViewModel, nav = nav, + modifier = Modifier.align(Alignment.TopCenter), ) } } @@ -281,7 +298,23 @@ fun HomeFeeds( accountViewModel: AccountViewModel, nav: INav, ) { - RefresheableBox(feedState, enablePullRefresh) { + val activeFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow + .collectAsStateWithLifecycle() + + val onRefresh: () -> Unit = { + feedState.invalidateData() + // Swiping down on Home should also re-issue the kind-5300 request(s) so the + // DVM(s) produce fresh feeds, not just re-render whatever's cached. + when (val filter = activeFilter) { + is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address) + is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } + else -> Unit + } + } + + RefresheableHomeBox(onRefresh, enablePullRefresh) { SaveableFeedContentState(feedState, scrollStateKey) { listState -> RenderFeedContentState( feedContentState = feedState, @@ -290,12 +323,28 @@ fun HomeFeeds( nav = nav, routeForLastRead = routeForLastRead, onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) }, - onEmpty = { HomeFeedEmpty(feedState::invalidateData) }, + onEmpty = { HomeFeedEmpty(onRefresh) }, ) } } } +@Composable +private fun RefresheableHomeBox( + onRefresh: () -> Unit, + enablePullRefresh: Boolean, + content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit, +) { + if (enablePullRefresh) { + RefresheableBox(onRefresh = onRefresh, content = content) + } else { + androidx.compose.foundation.layout.Box( + Modifier.fillMaxSize(), + content = content, + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun FeedLoaded( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 39459be38a..09488ead92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager( is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince) is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) + is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt new file mode 100644 index 0000000000..029546417a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent + +/** + * Builds relay REQ filters for a favorite-DVM home feed. + * + * Two distinct subscription kinds with two distinct relay sets: + * + * - **Content fetch** — for each of the user's outbox/proxy relays, request the + * note IDs and addressable references the DVM curated. Notes typically live on + * the user's normal relays, so this is where we fetch them. + * + * - **Response listen** — for each relay the DVM advertised (where it received + * the kind-5300 request and will publish its 6300/7000 reply), subscribe to + * future kind 6300 / 7000 events tagged with the request id. The DVM almost + * never publishes responses on the user's outbox, so listening anywhere else + * would silently miss them. + */ +fun filterHomePostsByAlgoFeedIds( + set: FavoriteAlgoFeedTopNavPerRelayFilterSet, + @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, + @Suppress("UNUSED_PARAMETER") defaultSince: Long?, +): List { + val out = mutableListOf() + + set.contentFetches.forEach { (relay, filter) -> + out += contentFetchFilters(relay, filter) + } + + if (set.requestIds.isNotEmpty()) { + val requestIds = set.requestIds.toList() + set.listenRelays.forEach { relay -> + out += responseListenFilter(relay, requestIds) + } + } + + return out +} + +private fun contentFetchFilters( + relay: NormalizedRelayUrl, + filter: FavoriteAlgoFeedTopNavPerRelayFilter, +): List { + val out = mutableListOf() + + if (filter.ids.isNotEmpty()) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + ids = filter.ids.toList(), + limit = filter.ids.size, + ), + ) + } + + if (filter.addresses.isNotEmpty()) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to filter.addresses.toList()), + limit = filter.addresses.size, + ), + ) + } + + return out +} + +private fun responseListenFilter( + relay: NormalizedRelayUrl, + requestIds: List, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to requestIds), + limit = 10 * requestIds.size, + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 864eaacc85..d7aa97489a 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -39,14 +40,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -90,7 +97,9 @@ private fun PictureCardImage( backgroundColor: MutableState, accountViewModel: AccountViewModel, ) { - val uri = note.toNostrUri() + val uri = remember(note) { note.toNostrUri() } + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } val images by remember(note) { @@ -111,26 +120,44 @@ private fun PictureCardImage( ) } - if (images.isNotEmpty()) { - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - if (images.size == 1) { + if (images.isEmpty()) return + + if (images.size == 1) { + val single = images.first() + val ratio = single.dim?.aspectRatio() ?: MediaAspectRatioCache.get(single.url) + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = listOf(single.url), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } }, + ) { + ZoomableContentView( + content = single, + images = images, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } else { + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = images.map { it.url }, + accountViewModel = accountViewModel, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + backdrop = { BlurhashGridBackdrop(images) }, + ) { + AutoNonlazyGrid(images.size) { ZoomableContentView( - content = images.first(), + content = images[it], images = images, roundedCorner = false, - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) - } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt index 97b1880d1d..31c89cb5c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt @@ -27,13 +27,16 @@ 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.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -63,15 +66,29 @@ fun PinnedNotesScreen( // Preload all pinned events so they don't load one-by-one when scrolling PreloadPinnedEvents(pinState, accountViewModel) - RenderPinnedNotesScreen(pinnedNotesFeedViewModel, accountViewModel, nav) + RenderPinnedNotesScreen(pinnedNotesFeedViewModel, pinState, accountViewModel, nav) } @Composable private fun RenderPinnedNotesScreen( pinnedNotesFeedViewModel: PinnedNotesFeedViewModel, + pinState: List?, accountViewModel: AccountViewModel, nav: INav, ) { + var bannerDismissed by remember { mutableStateOf(false) } + val deletedPins = + remember(pinState) { + pinState + ?.filter { note -> + note.event?.let(accountViewModel.account.cache::hasBeenDeleted) == true + }.orEmpty() + } + + LaunchedEffect(deletedPins) { + if (deletedPins.isEmpty()) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -80,6 +97,16 @@ private fun RenderPinnedNotesScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedPins.size, + onRemove = { + accountViewModel.removeDeletedPins(deletedPins.toSet()) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } RefresheableFeedView( pinnedNotesFeedViewModel, null, 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 47104756d8..f277f5de9c 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 @@ -129,6 +130,13 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.ProfileBadges) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.favorite_dvms_title, + icon = Icons.Outlined.AutoAwesome, + tint = tint, + onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, + ) HorizontalDivider() SettingsNavigationRow( title = R.string.reactions, 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 ba77fe0b6d..11385dd60b 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 @@ -42,15 +42,20 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent import kotlin.text.ifEmpty @@ -98,11 +103,13 @@ private fun VideoCardImage( val event = (event as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } + val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } val content by remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) mutableStateOf( if (isImage) { @@ -130,7 +137,16 @@ private fun VideoCardImage( ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { ZoomableContentView( content = content, roundedCorner = false, 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 3b6dca98a3..2efc3243a6 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 @@ -42,14 +42,19 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlin.text.ifEmpty @@ -95,12 +100,15 @@ private fun FileHeaderCardImage( ) { val fullUrl = event.url() ?: return + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + 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 dimensions = remember(note) { event.dimensions() } + val content by remember(note) { - val blurHash = event.blurhash() val hash = event.hash() - val dimensions = event.dimensions() val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() val mimeType = event.mimeType() @@ -130,7 +138,16 @@ private fun FileHeaderCardImage( ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(fullUrl) else emptyList(), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { ZoomableContentView( content = content, roundedCorner = false, diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6101c6c71e..e445b58bc5 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -270,6 +270,7 @@ Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní a proto zprávy časem mizí Veřejný chat + MLS skupina Metadata veřejného chatu Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. @@ -389,6 +390,7 @@ Obrázky Krátká videa Videa + Články Soukromé záložky Veřejné záložky Přidat do soukromých záložek @@ -396,8 +398,12 @@ Odebrat ze soukromých záložek Odebrat z veřejných záložek Připnuté poznámky + Vaše připnuté poznámky Připnout na profil Odepnout z profilu + %1$d položek v tomto seznamu bylo smazáno autorem. + Odebrat ze seznamu + Zavřít Seznamy záložek Ikona seznamu záložek Nový seznam záložek @@ -712,6 +718,8 @@ Nepodařilo se přijmout hovor Nepodařilo se vytvořit relaci hovoru Nastavení hovorů + Povolit hlasové a video hovory + Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány. Kvalita videa Maximální datový tok videa TURN / STUN servery @@ -937,6 +945,7 @@ Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. Hashtag-exkluzivní příspěvek Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí. + %1$d min čtení Načítání umístění Žádná lokace oprávnění Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující @@ -945,6 +954,7 @@ Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-17 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-17 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta. Aktivovat Veřejné + Skupina Nová veřejná nebo soukromá skupina Relé Soukromé @@ -1338,6 +1348,8 @@ DM schránka relé Uživatel přijímá soukromé zprávy na těchto přenašečích Vložte mezi 1–3 relé, která budou sloužit jako vaše soukromá schránka. Ostatní použijí tato relé k posílání DM zpráv vám. DM schránka relé by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené) + KeyPackage relays + Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení. Soukromá relé Vložte mezi 1–3 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele. Obecná relé @@ -1475,7 +1487,20 @@ Lokace Komunity Seznamy + Algoritmy zdrojů + Všechny oblíbené algoritmy zdrojů Relé + Přidat algoritmus zdroje k oblíbeným + Odebrat z oblíbených + Oblíbené algoritmy zdrojů + Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších. + Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. + Žádost %1$s o zdroj… + Žádost oblíbených algoritmů o zdroje… + Zpracování zdroje… + Tento algoritmus zdroje vyžaduje platbu + Algoritmus zdroje vrátil chybu + Zkusit znovu Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1819,6 +1844,41 @@ Přehrávání Auto + HLS nahrávání + Publikujte multi-rozlišení HLS na váš media server + Vyberte video + Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení. + Změnit + Titulek + Zadejte titulek videa + Popis + O čem je toto video? + Důvod (nepovinné) + Kodek + H.265 (lepší komprese) + H.265 není na tomto zařízení dostupný — používá se H.264. + Rozlišení + Zdrojové rozlišení: %1$d×%2$d + Bude vytvořeno: %1$s + (%1$s přeskočeno — nad zdrojem) + nad zdrojem — bude přeskočeno + Publikovat HD video + Publikování „%1$s“… + Překódování %1$s + Nahrát + Nahrávání %1$d z %2$d + Nahrávání %1$s (%2$d z %3$d) + Nahráno %1$d z %2$d + Publikování události… + Video publikováno + Vaše HD video je publikováno na Nostr. + Něco se pokazilo + Zobrazit poznámku + Hotovo + Zkusit znovu + Vytvořit poznámku po nahrání + Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním. + Vytvořit poznámku Akce balíčku Akce seznamu Akce záložky diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 98878b100b..78b9b9c616 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig und daher verschwinden Chat-Nachrichten im Laufe der Zeit Öffentlicher Chat + MLS-Gruppe Öffentliche Chat Metadaten Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. @@ -395,6 +396,7 @@ anz der Bedingungen ist erforderlich Bilder Kurzvideos Videos + Artikel Private Lesezeichen Öffentliche Lesezeichen Zu den privaten Lesezeichen hinzufügen @@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich Aus den privaten Lesezeichen entfernen Aus den öffentlichen Lesezeichen entfernen Angeheftete Notizen + Deine angepinnten Notizen An Profil anheften Von Profil lösen + %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. + Aus Liste entfernen + Verwerfen Lesezeichenlisten Symbol für Lesezeichenliste Neue Lesezeichenliste @@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich Anruf konnte nicht angenommen werden Anrufsitzung konnte nicht erstellt werden Anrufeinstellungen + Sprach- und Videoanrufe aktivieren + Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. Videoqualität Maximale Video-Bitrate TURN- / STUN-Server @@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen. Hashtag-exklusive Beitrag Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen. + %1$d Min. Lesezeit Standort wird geladen Keine Standortberechtigungen Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten @@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet. Aktivieren Öffentlich + Gruppe Neue öffentliche oder private Gruppe Relais Privat @@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich DM-Posteingangsrelais Der Benutzer empfängt Direktnachrichten auf diesen Relays Fügen Sie 1–3 Relais ein, die als Ihr privater Posteingang dienen sollen. Andere werden diese Relais verwenden, um Ihnen DMs zu senden. DM-Posteingangsrelais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt) + KeyPackage-Relays + Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben. Private Relais Fügen Sie zwischen 1–3 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen. Allgemeine Relais @@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich Standorte Gemeinschaften Listen + Feed-Algorithmen + Alle bevorzugten Feed-Algorithmen Relais + Feed-Algorithmus zu Favoriten hinzufügen + Aus Favoriten entfernen + Bevorzugte Feed-Algorithmen + Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. + Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. + Frage %1$s nach einem Feed… + Frage deine bevorzugten Feed-Algorithmen nach Feeds… + Feed wird verarbeitet… + Dieser Feed-Algorithmus erfordert eine Zahlung + Der Feed-Algorithmus hat einen Fehler zurückgegeben + Wiederholen Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich Wiedergabe Auto + HLS-Upload + Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver + Video auswählen + Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. + Ändern + Titel + Gib deinem Video einen Titel + Beschreibung + Worum geht es in diesem Video? + Grund (optional) + H.265 (bessere Komprimierung) + H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. + Versionen + Quellauflösung: %1$d×%2$d + Wird erstellt: %1$s + (%1$s übersprungen — über Quelle) + über Quelle — wird übersprungen + HD-Video veröffentlichen + Veröffentliche „%1$s“… + Transkodiere %1$s + Hochladen + Lade %1$d von %2$d hoch + Lade %1$s hoch (%2$d von %3$d) + %1$d von %2$d hochgeladen + Veröffentliche Ereignis… + Video veröffentlicht + Dein HD-Video ist auf Nostr live. + Etwas ist schiefgelaufen + Notiz anzeigen + Fertig + Erneut versuchen + Notiz nach Upload entwerfen + Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst. + Notiz entwerfen Paket-Aktionen Listenaktionen Lesezeichen-Aktionen diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 100418540e..568400a756 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 df8ce90483..5107915442 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 9c980c163f..473b78618e 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1346,6 +1346,7 @@ Odbiorcze transmitery DM Użytkownik otrzymuje DM na tych transmiterach Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny) + Pakiety kluczy Transmitery pakietu kluczy Transmitery, na których publikowane są Twoje pakiety kluczy MLS (MIP-00). Inni użytkownicy pobierają te pakiety kluczy, aby zaprosić Cię do czatów grupowych w aplikacji Marmot. Wprowadź od 1 do 3 transmiterów, które akceptują zdarzenia związane z pakietami kluczy od Ciebie i zezwalają na publiczny dostęp do tych danych. Transmitery Prywatne diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 956ccfc2d7..53ffc5eee8 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -270,6 +270,7 @@ São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo Chat público + Grupo MLS Metadados do Chat Público Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. @@ -389,6 +390,7 @@ Imagens Curtas Vídeos + Artigos Itens Salvos Privados Itens Salvos Públicos Adicionar aos Itens Salvos Privados @@ -396,8 +398,12 @@ Remover dos Itens Salvos Privados Remover dos Itens Salvos Públicos Notas Fixadas + Suas notas fixadas Fixar no Perfil Desafixar do Perfil + %1$d item(ns) desta lista foram excluídos pelos seus autores. + Remover da lista + Dispensar Listas de favoritos Ícone da lista de favoritos Nova lista de favoritos @@ -712,6 +718,8 @@ Falha ao aceitar chamada Falha ao criar sessão de chamada Configurações de Chamada + Ativar chamadas de voz e vídeo + Quando desativado, os botões de chamada ficam ocultos nas telas de conversa e todas as chamadas recebidas são silenciosamente ignoradas. Qualidade do Vídeo Taxa de Bits Máxima de Vídeo Servidores TURN / STUN @@ -937,6 +945,7 @@ Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. Postagem exclusiva de Hashtag Somente seguidores da hashtag verão isso. Seus seguidores gerais não verão isso. + %1$d min de leitura Carregando localização Sem permissões para localização Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador @@ -945,6 +954,7 @@ Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-17 (GiftWrapped, Sealed Direct and Group Messages). NIP-17 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível. Ativar Público + Grupo Novo Grupo Público ou Privado Relé Privado @@ -1338,6 +1348,8 @@ Relés de Caixa de Entrada de DM O usuário recebe mensagens diretas (DMs) nesses relays Insira entre 1–3 relés para servir como sua caixa de entrada privada. Outros usarão esses relés para enviar DMs para você. Relés de Caixa de Entrada de DM devem aceitar qualquer mensagem de qualquer pessoa, mas permitir apenas você baixá-las. Boas opções são:\n - inbox.nostr.wine (pago)\n - you.nostr1.com (relés pessoais - pago) + Relays de KeyPackage + Relays onde seus MLS KeyPackages são publicados (MIP-00). Outros usuários os buscam para convidá-lo para chats em grupo Marmot. Insira de 1 a 3 relays que aceitem eventos KeyPackage seus e permitam leitura pública. Relés privados Insira entre 1–3 retransmissores para armazenar eventos que ninguém mais possa ver, como seus rascunhos e/ou configurações de aplicativo. Idealmente, esses relés são locais ou requerem autenticação antes de baixar o conteúdo de cada usuário. Relés Gerais @@ -1475,7 +1487,20 @@ Localizações Comunidades Listas + Algoritmos de feed + Todos os algoritmos de feed favoritos Relés + Adicionar algoritmo de feed aos favoritos + Remover dos favoritos + Algoritmos de feed favoritos + Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. + Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. + Solicitando feed a %1$s… + Solicitando feeds aos seus algoritmos favoritos… + Processando seu feed… + Este algoritmo de feed requer pagamento + O algoritmo de feed retornou um erro + Tentar novamente Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1819,6 +1844,40 @@ Reprodução Auto + Upload HLS + Publique HLS multi-resolução em seu servidor de mídia + Escolher um vídeo + Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. + Trocar + Título + Dê um título ao seu vídeo + Descrição + Sobre o que é este vídeo? + Motivo (opcional) + H.265 (melhor compressão) + H.265 não disponível neste dispositivo — usando H.264. + Versões + Resolução da origem: %1$d×%2$d + Será gerado: %1$s + (%1$s ignorado — acima da origem) + acima da origem — será ignorado + Publicar vídeo HD + Publicando “%1$s”… + Transcodificando %1$s + Enviar + Enviando %1$d de %2$d + Enviando %1$s (%2$d de %3$d) + Enviado %1$d de %2$d + Publicando evento… + Vídeo publicado + Seu vídeo HD está publicado no Nostr. + Algo deu errado + Ver nota + Concluído + Tentar novamente + Rascunho de nota após upload + Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar. + Rascunhar nota Ações do pacote Ações da lista Ações de favorito diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index c5d5e3161b..575e988fda 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -281,6 +281,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne zato sporočila v klepetu sčasoma izginejo Javni pogovor + MLS skupina Metapodatki Javnega Klepeta Javni klepeti so vidni vsem na Nostru in vsakdo se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. @@ -402,6 +403,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Fotografije Kratki posnetki Videoposnetki + Članki Privatni zaznamki Javni zaznamki Dodaj v privatne zaznamke @@ -409,8 +411,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odstrani iz privatnih zaznamkov Odstrani iz javnih zaznamkov Pripeti zapiski + Vaši pripeti zapiski Pripni k profilu Odpni od profila + Odstrani iz seznama + Prekliči Seznam zaznamkov Ikona za seznam zaznamkov Nov seznam zaznamkov @@ -727,6 +732,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Sprejem klica ni uspel Vzpostavitev klicne seje ni uspela Klicne nastavitve + Vklop glasovnih in video klicev + Če funkcijo izklopite, bodo gumbi za klice skriti, vsi dohodni klici pa se bodo tiho zavrnili. Kakovost videa Najvišja bitna hitrost videa Strežniki TURN / STUN @@ -952,6 +959,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. Ekskluzivna objava ključnika Vidno bo le sledilcem tega ključnika. Tvoji splošni sledilci tega ne bojo videli. + %1$d min branja Nalaganje lokacije Brez dovoljenj za lokacijo Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo @@ -960,6 +968,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Za aktivacijo tega načina mora Amethyst poslati sporočilo NIP-17 (GiftWrapped, šifrirana neposredna in skupinska sporočila). NIP-17 je nov in večina Nostr odjemalcev ga še ni implementirala. Prepričajte se, da prejemnik uporablja združljiv Nostr odjemalec. Aktiviraj Javno + Skupina Nova javna ali zasebna skupina Rele Zasebno @@ -1355,6 +1364,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Releji predala zasebnih sporočil Uporabnik prejema ZS prek teh relejev Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Drugi bodo te releje uporabljali za pošiljanje zasebnih sporočil vam. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Dobre možnosti so:\n - inbox.nostr.wine (plačljiv)\n - auth.nostr1.com (brezplačen)\n - you.nostr1.com (osebni releji - plačljivi) + KeyPackage-i + KeyPackage Releji + Releji, kjer so objavljeni vaši MLS KeyPackage-i (MIP-00). Drugi uporabniki jih pridobijo, da vas lahko povabijo v skupinske klepete Marmot. Vstavite od 1 do 3 releje, ki sprejemajo vaše KeyPackage dogodke in omogočajo javno branje. Zasebni domači releji Vnesite 1–3 releje za shranjevanje dogodkov, ki jih nihče drug ne more videti, kot so vaši osnutki in/ali nastavitve aplikacije. Idealno je, da so ti releji bodisi lokalni ali zahtevajo preverjanje pristnosti pred prenosom vsebine posameznega uporabnika. Splošni releji @@ -1834,7 +1846,45 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Možnosti profila Možnosti predstavnosti Predvajaj + Samodejno + Naloži HLS + Objava HLS v več ločljivostih na medijski strežnik + Izberi video + Vaš video bo pripravljen v več ločljivostih, da bo predvajanje teklo gladko na vsaki povezavi. + Sprememba + Naslov + Dodajte naslov videa + Opis + Kaj je vsebina tega videa? + Razlog (neobvezno) + Kodek + H.265 (boljše stiskanje) + H.264 + Naprava ne podpira H.265 — izbran bo H.264. + Izvedbe + Resolucija vira: %1$d×%2$d + Ustvarilo bo: %1$s + (%1$s preskočen — zgornji vir) + %1$d kbps + Zgornji vir — bo preskočen + Objavite HD video + Objavljam “%1$s”… + Priprava videa: %1$s + Naložite + Nalagam %1$d of %2$d + Nalagam %1$s (%2$d of %3$d) + Naloženo %1$d of %2$d + Dogodek se objavlja… + Video je objavljen + Vaš HD-video je dostopen na Nostru. + Prišlo je do napake + Poglej zapisek + Končano + Poskusi znova + Osnutek zapiska po nalaganju + Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš. + Osnutek zapiska Možnosti paketa Možnosti seznama Možnosti zaznamkov diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5c425a64b4..ed7eba1dc3 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -270,6 +270,7 @@ De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade och därmed försvinner chattmeddelanden över tiden Publik Chat + MLS-grupp Metadata för offentlig chatt Offentliga chattar är synliga för alla på Nostr och alla kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. @@ -389,6 +390,7 @@ Bilder Kortfilmer Videor + Artiklar Privata Bokmärken Publika Bokmärken Lägg till i Privata Bokmärken @@ -396,8 +398,12 @@ Ta bort från Privata Bokmärken Ta bort från Publika Bokmärken Fästa anteckningar + Dina fastnålade anteckningar Fäst på profil Ta bort från profil + %1$d objekt i den här listan har raderats av sina författare. + Ta bort från lista + Avvisa Bokmärkeslistor Ikon för bokmärkeslista Ny bokmärkeslista @@ -711,6 +717,8 @@ Kunde inte ta emot samtal Kunde inte skapa samtalssession Samtalsinställningar + Aktivera röst- och videosamtal + När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. Videokvalitet Maximal videobitfrekvens TURN- / STUN-servrar @@ -936,6 +944,7 @@ Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. Hashtag-exklusivt inlägg Endast anhängare av hashtaggen kommer att se den. Dina generella följare kommer inte att se den. + %1$d min läsning Laddar position Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande @@ -944,6 +953,7 @@ För att aktivera denna funktion kräver det att Amethyst skickar ett NIP-17 meddelande (GiftWrapped, Förseglade Direkta och Gruppmeddelanden). NIP-17 är nytt och de flesta klienter har ännu inte implementerat det. Se till att mottagaren använder en kompatibel klient. Aktivera Publik + Grupp Ny offentlig eller privat grupp Relä Privat @@ -1337,6 +1347,8 @@ DM inkorgsreläer Användaren tar emot DM:s på dessa reläer Sätt in mellan 1–3 reläer som ska fungera som din privata inkorg. Andra kommer att använda dessa reläer för att skicka DM till dig. DM inkorgsreläer bör acceptera alla meddelanden från vem som helst, men endast tillåta dig att ladda ner dem. Bra alternativ är:\n - inbox.nostr.wine (betald)\n - you.nostr1.com (personliga reläer - betald) + KeyPackage-relays + Relays där dina MLS KeyPackages publiceras (MIP-00). Andra användare hämtar dessa KeyPackages för att bjuda in dig till Marmot-gruppchatter. Lägg till mellan 1–3 relays som accepterar KeyPackage-händelser från dig och tillåter offentlig läsning. Privata reläer Infoga mellan 1–3 reläer för att lagra händelser som ingen annan kan se, som dina Utkast och/eller appinställningar. Helst är dessa reläer antingen lokala eller kräver autentisering innan du laddar ner varje användares innehåll. Allmänna reläer @@ -1474,7 +1486,20 @@ Platser Gemenskaper Listor + Flödesalgoritmer + Alla favorit-flödesalgoritmer Reläer + Lägg till flödesalgoritm i favoriter + Ta bort från favoriter + Favorit-flödesalgoritmer + Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. + Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. + Frågar %1$s om ett flöde… + Frågar dina favorit-flödesalgoritmer om flöden… + Bearbetar ditt flöde… + Den här flödesalgoritmen kräver betalning + Flödesalgoritmen returnerade ett fel + Försök igen Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1818,6 +1843,40 @@ Uppspelning Auto + HLS-uppladdning + Publicera HLS i flera upplösningar till din mediaserver + Välj en video + Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. + Ändra + Titel + Ge din video en titel + Beskrivning + Vad handlar den här videon om? + Anledning (valfritt) + H.265 (bättre komprimering) + H.265 inte tillgängligt på den här enheten — växlar till H.264. + Versioner + Källupplösning: %1$d×%2$d + Kommer att skapa: %1$s + (%1$s hoppas över — över källan) + över källa — kommer att hoppas över + Publicera HD-video + Publicerar ”%1$s”… + Transkodar %1$s + Ladda upp + Laddar upp %1$d av %2$d + Laddar upp %1$s (%2$d av %3$d) + Uppladdade %1$d av %2$d + Publicerar händelse… + Video publicerad + Din HD-video är live på Nostr. + Något gick fel + Visa anteckning + Klar + Försök igen + Utkast till anteckning efter uppladdning + Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering. + Utkast till anteckning Paketåtgärder Liståtgärder Bokmärkesåtgärder diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 2c4e4d7a61..9f11a256a3 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1350,6 +1350,7 @@ 私信收件箱中继 用户接收到这些中继的私信 设置 1 ~ 3 个中继作为您的私人收件箱。其他人将使用这些中继向您发送私信。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。示例:\n - inbox.nostr.wine(付费)\n - you.nostr1.com(个人专用中继 - 付费) + 密钥包 密钥包中继 发布你的 MLS 密钥包发布的中继(MIP-00)。其他用户获取这些密钥包来邀请你加入 Marmot 群聊。插入1~3个接受来自你的密钥包事件的中继并允许公开读取。 私人中继 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 43f49356db..b375737b94 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -469,6 +469,10 @@ Pin to Profile Unpin from Profile + %1$d item(s) in this list have been deleted by their author. + Remove from list + Dismiss + Bookmark Lists Icon for bookmark list New Bookmark List @@ -1763,8 +1767,22 @@ Locations Communities Lists + Feed Algorithms + All favorite feed algorithms Relays + Add feed algorithm to favorites + Remove from favorites + Favorite Feed Algorithms + Feed algorithms you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favorite feed algorithms yet. Open Discover, tap one, and star it to add it here. + Asking %1$s for a feed… + Asking your favorite feed algorithms for feeds… + Processing your feed… + This feed algorithm requires payment + The feed algorithm returned an error + Retry + Log off on device lock Private Message Public Message diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt new file mode 100644 index 0000000000..0e8594af85 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FavoriteAlgoFeedTopNavFilterTest { + private fun textNote(id: String) = TextNoteEvent(id = id, pubKey = "a".repeat(64), createdAt = 1, tags = emptyArray(), content = "", sig = "x".repeat(128)) + + private fun longFormNote( + pubkey: String, + dTag: String, + ) = LongTextNoteEvent( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = 1, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "x".repeat(128), + ) + + private val dvmAddress = Address(31990, "d".repeat(64), "content") + + @Test + fun matchesNoteWhoseIdIsInAcceptedSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = setOf("1".repeat(64)), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(textNote("1".repeat(64)))) + } + + @Test + fun rejectsNoteNotInAcceptedSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = setOf("1".repeat(64)), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertFalse(filter.match(textNote("2".repeat(64)))) + } + + @Test + fun matchesAddressableEventByAddressTag() { + val articleAuthor = "c".repeat(64) + val articleDTag = "my-post" + val articleAddress = "30023:$articleAuthor:$articleDTag" + + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = setOf(articleAddress), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(longFormNote(articleAuthor, articleDTag))) + } + + @Test + fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + // passing a LocalCache is only needed because the method demands it; + // FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it. + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds.isEmpty()) + } + + @Test + fun nonNullRequestIdProducesSingletonInFilterSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = "9".repeat(64), + ) + + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds == setOf("9".repeat(64))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt new file mode 100644 index 0000000000..5ae5d27a18 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilterHomePostsByAlgoFeedIdsTest { + private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! + private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! + + @Test + fun emptyFilterSetProducesNoRequests() { + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + assertTrue(filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null).isEmpty()) + } + + @Test + fun contentFetchIssuedOnUserRelayWithIdsFilter() { + val ids = setOf("a".repeat(64), "b".repeat(64)) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val single = filters.single() + assertEquals(userRelay, single.relay) + assertEquals(ids.sorted(), single.filter.ids?.sorted()) + // Content fetch should not be restricted to a kind — the DVM curates freely. + assertEquals(null, single.filter.kinds) + } + + @Test + fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() { + val requestId = "9".repeat(64) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val listen = filters.single() + assertEquals(dvmRelay, listen.relay) + assertEquals( + listOf(NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND), + listen.filter.kinds, + ) + val eTag = listen.filter.tags?.get("e") + assertNotNull(eTag) + assertEquals(listOf(requestId), eTag) + } + + @Test + fun mergedRequestIdsAllRideOnOneListenFilterPerRelay() { + val req1 = "1".repeat(64) + val req2 = "2".repeat(64) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(req1, req2), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val eTag = + filters + .single() + .filter.tags + ?.get("e") + .orEmpty() + assertTrue(eTag.containsAll(listOf(req1, req2))) + assertEquals(2, eTag.size) + } + + @Test + fun contentAndListenSubscriptionsSplitAcrossTheirRespectiveRelays() { + val ids = setOf("a".repeat(64)) + val requestId = "9".repeat(64) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(2, filters.size) + assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null }) + assertTrue(filters.any { it.relay == dvmRelay && it.filter.kinds != null }) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt index c3fdee824a..8b56b6e2ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -298,4 +300,46 @@ class BookmarkListState( null } } + + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ): BookmarkListEvent? { + val currentList = getBookmarkList() ?: return null + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return null + + val newPublicTags = filterOutDeletedBookmarks(currentList.tags, deletedEventIds, deletedAddresses) + val oldPrivateTags = currentList.privateTags(signer) + + return if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return null + BookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = signer, + ) + } else { + val newPrivateTags = filterOutDeletedBookmarks(oldPrivateTags, deletedEventIds, deletedAddresses) + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return null + BookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = signer, + ) + } + } } + +internal fun filterOutDeletedBookmarks( + tags: TagArray, + deletedEventIds: Set, + deletedAddresses: Set
, +): TagArray = + tags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt index e9fe062b86..67f499deba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -210,4 +211,32 @@ class OldBookmarkListState( } else { publicBookmarkEventIdSet.value.contains(note.idHex) } + + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ): OldBookmarkListEvent? { + val currentList = getBookmarkList() ?: return null + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return null + + val newPublicTags = filterOutDeletedBookmarks(currentList.tags, deletedEventIds, deletedAddresses) + val oldPrivateTags = currentList.privateTags(signer) + + return if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return null + OldBookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = signer, + ) + } else { + val newPrivateTags = filterOutDeletedBookmarks(oldPrivateTags, deletedEventIds, deletedAddresses) + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return null + OldBookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = signer, + ) + } + } } 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 d390a18348..9b1a36cbb6 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 bfc0b30cf2..e832568c0e 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 79de431fdd..c0aa426c06 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 0000000000..039d8b4e26 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfParserTest { + @Test + fun detectsPdfByExtension() { + val url = "https://example.com/docs/paper.pdf" + val state = RichTextParser().parseText(url, EmptyTagList, null) + + val pdfMedia = state.mediaForPager[url] + assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL") + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}") + assertEquals(url, segment.segmentText) + } + + @Test + fun detectsPdfFromImetaMimeTypeWithoutExtension() { + val url = "https://files.example.com/abcd1234" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m application/pdf"), + ), + ) + + val state = RichTextParser().parseText(url, tags, null) + + val pdfMedia = state.mediaForPager[url] + assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag") + assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType) + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}") + } + + @Test + fun isPdfUrlHelperMatchesPdfExtension() { + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf")) + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF")) + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc")) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index f06acf2e71..11e9521bb2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -69,6 +69,22 @@ object DesktopPreferences { prefs.put(KEY_LAYOUT_MODE, value) } + private const val KEY_WORKSPACES = "workspaces" + + var workspaces: String + get() = prefs.get(KEY_WORKSPACES, "") + set(value) { + prefs.put(KEY_WORKSPACES, value) + } + + private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items" + + var pinnedNavItems: String + get() = prefs.get(KEY_PINNED_NAV_ITEMS, "") + set(value) { + prefs.put(KEY_PINNED_NAV_ITEMS, value) + } + private const val KEY_BLOSSOM_SERVERS = "blossom_servers" private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7d72e82e39..f8c28cd80f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -88,12 +88,17 @@ import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker -import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog +import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout +import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState +import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace +import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager +import com.vitorpamplona.amethyst.desktop.ui.deck.param import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -190,9 +195,10 @@ fun main() { var replyToNote by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() val deckState = remember { DeckState(deckScope).also { it.load() } } + val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() - var showAddColumnDialog by remember { mutableStateOf(false) } + var showAppDrawer by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -237,6 +243,42 @@ fun main() { }, onClick = { showComposeDialog = true }, ) + Item( + "Save as Workspace", + shortcut = + if (isMacOS) { + KeyShortcut(Key.S, meta = true, shift = true) + } else { + KeyShortcut(Key.S, ctrl = true, shift = true) + }, + onClick = { + if (workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES) { + val columns = + deckState.columns.value.map { col -> + Workspace.WorkspaceColumn( + typeKey = col.type.typeKey(), + param = col.type.param(), + width = col.width, + ) + } + val ws = + Workspace( + name = "Workspace ${workspaceManager.workspaces.value.size + 1}", + iconName = "Star", + layoutMode = layoutMode, + columns = columns, + singlePaneScreens = + if (layoutMode == LayoutMode.SINGLE_PANE) { + columns.map { it.typeKey } + } else { + emptyList() + }, + ) + workspaceManager.addWorkspace(ws) + } + }, + enabled = workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES, + ) Separator() Item( "Settings", @@ -299,6 +341,17 @@ fun main() { ) } Menu("View") { + Item( + "App Drawer", + shortcut = + if (isMacOS) { + KeyShortcut(Key.K, meta = true) + } else { + KeyShortcut(Key.K, ctrl = true) + }, + onClick = { showAppDrawer = !showAppDrawer }, + ) + Separator() Item( if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", shortcut = @@ -323,7 +376,7 @@ fun main() { } else { KeyShortcut(Key.T, ctrl = true) }, - onClick = { showAddColumnDialog = true }, + onClick = { showAppDrawer = true }, ) Item( "Close Column", @@ -432,10 +485,15 @@ fun main() { key(appRestartKey) { App( layoutMode = layoutMode, + onLayoutModeChange = { newMode -> + layoutMode = newMode + DesktopPreferences.layoutMode = newMode.name + }, deckState = deckState, + workspaceManager = workspaceManager, accountManager = accountManager, showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, + showAppDrawer = showAppDrawer, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -445,8 +503,8 @@ fun main() { showComposeDialog = false replyToNote = null }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, + onDismissAppDrawer = { showAppDrawer = false }, + onShowAppDrawer = { showAppDrawer = true }, replyToNote = replyToNote, onRestartApp = { appRestartKey++ }, torManager = torManager, @@ -463,15 +521,17 @@ fun main() { @Composable fun App( layoutMode: LayoutMode, + onLayoutModeChange: (LayoutMode) -> Unit, deckState: DeckState, + workspaceManager: WorkspaceManager, accountManager: AccountManager, showComposeDialog: Boolean, - showAddColumnDialog: Boolean, + showAppDrawer: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, - onDismissAddColumnDialog: () -> Unit, - onShowAddColumnDialog: () -> Unit, + onDismissAppDrawer: () -> Unit, + onShowAppDrawer: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, onRestartApp: () -> Unit = {}, torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, @@ -479,6 +539,9 @@ fun App( externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { + val singlePaneState = remember { SinglePaneState() } + val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } } + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings var torSettings by remember { mutableStateOf( @@ -683,6 +746,9 @@ fun App( MainContent( layoutMode = layoutMode, deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, @@ -693,7 +759,7 @@ fun App( torStatus = currentTorStatus, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, - onShowAddColumnDialog = onShowAddColumnDialog, + onShowAppDrawer = onShowAppDrawer, ) } @@ -707,14 +773,53 @@ fun App( ) } - // Add column dialog - if (showAddColumnDialog) { - AddColumnDialog( - onDismiss = onDismissAddColumnDialog, - onAdd = { type -> - deckState.addColumn(type) - onDismissAddColumnDialog() + // App Drawer overlay + if (showAppDrawer) { + val openColumns by deckState.columns.collectAsState() + AppDrawer( + openColumnTypes = + if (layoutMode == LayoutMode.DECK) { + openColumns.map { it.type.typeKey() }.toSet() + } else { + emptySet() + }, + pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + // Switch layout mode to match workspace + onLayoutModeChange(ws.layoutMode) + // Load columns or single pane screen + when (ws.layoutMode) { + LayoutMode.DECK -> { + deckState.loadFromWorkspace(ws.columns) + } + + LayoutMode.SINGLE_PANE -> { + // Load nav bar from workspace + navigate to first screen + pinnedNavBarState.loadFromWorkspace() + val firstKey = + ws.singlePaneScreens.firstOrNull() ?: "home" + val type = DeckState.parseColumnTypeFromKey(firstKey) + if (type != null) singlePaneState.navigate(type) + } + } }, + onSelectScreen = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } + } + }, + onDismiss = onDismissAppDrawer, ) } } @@ -736,6 +841,9 @@ fun App( fun MainContent( layoutMode: LayoutMode, deckState: DeckState, + workspaceManager: WorkspaceManager, + singlePaneState: SinglePaneState, + pinnedNavBarState: PinnedNavBarState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -746,7 +854,7 @@ fun MainContent( torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, - onShowAddColumnDialog: () -> Unit, + onShowAppDrawer: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -893,6 +1001,9 @@ fun MainContent( highlightStore = highlightStore, draftStore = draftStore, appScope = appScope, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + onOpenAppDrawer = onShowAppDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, @@ -906,7 +1017,7 @@ fun MainContent( LayoutMode.DECK -> { if (!isImmersive) { DeckSidebar( - onAddColumn = onShowAddColumnDialog, + onAddColumn = onShowAppDrawer, onOpenSettings = { if (deckState.hasColumnOfType(DeckColumnType.Settings)) { deckState.focusExistingColumn(DeckColumnType.Settings) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt deleted file mode 100644 index f85a4707c5..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ /dev/null @@ -1,165 +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.desktop.ui.deck - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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 - -private val COLUMN_OPTIONS = - listOf( - DeckColumnType.HomeFeed, - DeckColumnType.Notifications, - DeckColumnType.Messages, - DeckColumnType.Search, - DeckColumnType.Reads, - DeckColumnType.Drafts, - DeckColumnType.MyHighlights, - DeckColumnType.Bookmarks, - DeckColumnType.GlobalFeed, - DeckColumnType.MyProfile, - DeckColumnType.Chess, - ) - -@Composable -fun AddColumnDialog( - onDismiss: () -> Unit, - onAdd: (DeckColumnType) -> Unit, -) { - var hashtagInput by remember { mutableStateOf("") } - var showHashtagInput by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add Column") }, - text = { - Column { - if (showHashtagInput) { - Text( - "Enter hashtag:", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = hashtagInput, - onValueChange = { hashtagInput = it.removePrefix("#") }, - label = { Text("Hashtag") }, - placeholder = { Text("bitcoin") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } else { - COLUMN_OPTIONS.forEach { type -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onAdd(type) } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - Icon( - imageVector = type.icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Text( - type.title(), - style = MaterialTheme.typography.bodyLarge, - ) - } - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { showHashtagInput = true } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = DeckColumnType.Hashtag("").icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Text( - "Hashtag...", - style = MaterialTheme.typography.bodyLarge, - ) - } - } - } - }, - confirmButton = { - if (showHashtagInput) { - Button( - onClick = { - if (hashtagInput.isNotBlank()) { - onAdd(DeckColumnType.Hashtag(hashtagInput.trim())) - } - }, - enabled = hashtagInput.isNotBlank(), - ) { - Text("Add") - } - } - }, - dismissButton = { - TextButton(onClick = { - if (showHashtagInput) { - showHashtagInput = false - } else { - onDismiss() - } - }) { - Text(if (showHashtagInput) "Back" else "Cancel") - } - }, - ) -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt new file mode 100644 index 0000000000..f98466bd40 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -0,0 +1,1201 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.LayoutMode +import kotlinx.coroutines.delay + +// -- Screen categories -- + +enum class ScreenCategory( + val title: String, + val icon: ImageVector, +) { + SOCIAL("Social", Icons.Default.Groups), + LONG_FORM("Long-Form", Icons.AutoMirrored.Filled.Article), + DISCOVERY("Discovery", Icons.Default.Explore), + IDENTITY("Identity", Icons.Default.Person), + PLAY("Play", Icons.Default.SportsEsports), +} + +// -- Extensions on DeckColumnType -- + +fun DeckColumnType.category(): ScreenCategory = + when (this) { + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.GlobalFeed, + -> ScreenCategory.SOCIAL + + DeckColumnType.Reads, + DeckColumnType.Drafts, + is DeckColumnType.Editor, + DeckColumnType.MyHighlights, + -> ScreenCategory.LONG_FORM + + DeckColumnType.Search, + is DeckColumnType.Hashtag, + DeckColumnType.Bookmarks, + -> ScreenCategory.DISCOVERY + + DeckColumnType.MyProfile, + DeckColumnType.Settings, + -> ScreenCategory.IDENTITY + + DeckColumnType.Chess -> ScreenCategory.PLAY + + // Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness + is DeckColumnType.Profile, + is DeckColumnType.Thread, + is DeckColumnType.Article, + -> ScreenCategory.SOCIAL + } + +fun DeckColumnType.requiresInput(): Boolean = + when (this) { + is DeckColumnType.Hashtag -> true + else -> false + } + +fun DeckColumnType.param(): String? = + when (this) { + is DeckColumnType.Profile -> pubKeyHex + is DeckColumnType.Thread -> noteId + is DeckColumnType.Hashtag -> tag + is DeckColumnType.Editor -> draftSlug + is DeckColumnType.Article -> addressTag + else -> null + } + +val LAUNCHABLE_SCREENS: List = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.GlobalFeed, + DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.Editor(), + DeckColumnType.MyHighlights, + DeckColumnType.Search, + DeckColumnType.Hashtag(""), + DeckColumnType.Bookmarks, + DeckColumnType.MyProfile, + DeckColumnType.Settings, + DeckColumnType.Chess, + ) + +// -- Tabs -- + +enum class AppDrawerTab { + SCREENS, + WORKSPACES, +} + +// -- State -- + +@Stable +private class AppDrawerState { + var searchQuery by mutableStateOf("") + var selectedIndex by mutableStateOf(0) + var activeTab by mutableStateOf(AppDrawerTab.SCREENS) + var hashtagInput by mutableStateOf("") + var awaitingHashtag by mutableStateOf(false) + + val filteredScreens: List by derivedStateOf { + if (searchQuery.isBlank()) { + LAUNCHABLE_SCREENS + } else { + LAUNCHABLE_SCREENS.filter { + it.title().contains(searchQuery, ignoreCase = true) || + it.category().title.contains(searchQuery, ignoreCase = true) + } + } + } + + val groupedScreens: Map> by derivedStateOf { + filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() } + } + + val isSearching: Boolean by derivedStateOf { searchQuery.isNotBlank() } + + fun filteredWorkspaces(allWorkspaces: List): List = + if (searchQuery.isBlank()) { + allWorkspaces + } else { + allWorkspaces.filter { ws -> + ws.name.contains(searchQuery, ignoreCase = true) || + ws.columns.any { col -> + val displayName = + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() + ?: col.typeKey + displayName.contains(searchQuery, ignoreCase = true) + } + } + } + + fun switchTab(tab: AppDrawerTab) { + activeTab = tab + selectedIndex = 0 + } + + fun moveSelection( + delta: Int, + wsCount: Int = 0, + ) { + val size = + when { + isSearching -> wsCount + filteredScreens.size + activeTab == AppDrawerTab.WORKSPACES -> wsCount + else -> filteredScreens.size + } + if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) + } + + fun select( + screen: DeckColumnType, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (screen.requiresInput()) { + awaitingHashtag = true + hashtagInput = "" + } else { + onSelectScreen(screen) + onDismiss() + } + } + + fun selectCurrent( + filteredWs: List, + onSelectScreen: (DeckColumnType) -> Unit, + onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, + ) { + if (isSearching) { + // Unified: workspaces first, then screens + val wsCount = filteredWs.size + if (selectedIndex < wsCount) { + onSwitchWorkspace(filteredWs[selectedIndex]) + onDismiss() + } else { + val screenIdx = selectedIndex - wsCount + filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + } + } else { + when (activeTab) { + AppDrawerTab.SCREENS -> { + filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } + } + + AppDrawerTab.WORKSPACES -> { + filteredWs.getOrNull(selectedIndex)?.let { + onSwitchWorkspace(it) + onDismiss() + } + } + } + } + } + + fun confirmHashtag( + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (hashtagInput.isBlank()) return + onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim())) + onDismiss() + } +} + +// -- Composables -- + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AppDrawer( + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val state = remember { AppDrawerState() } + val searchFocusRequester = remember { FocusRequester() } + val allWorkspaces by workspaceManager.workspaces.collectAsState() + val filteredWs by remember(allWorkspaces, state.searchQuery) { + derivedStateOf { state.filteredWorkspaces(allWorkspaces) } + } + + LaunchedEffect(Unit) { + delay(50) + searchFocusRequester.requestFocus() + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (state.awaitingHashtag) { + state.awaitingHashtag = false + } else { + onDismiss() + } + true + } + + Key.DirectionDown -> { + state.moveSelection(1, filteredWs.size) + true + } + + Key.DirectionUp -> { + state.moveSelection(-1, filteredWs.size) + true + } + + Key.Enter -> { + if (state.awaitingHashtag) { + state.confirmHashtag(onSelectScreen, onDismiss) + } else { + state.selectCurrent( + filteredWs, + onSelectScreen, + onSwitchWorkspace, + onDismiss, + ) + } + true + } + + else -> { + false + } + } + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = + Modifier + .fillMaxWidth(0.55f) + .fillMaxHeight(0.7f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { /* consume click — prevent propagation to scrim */ }, + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp, + ) { + Column { + TextField( + value = state.searchQuery, + onValueChange = { + state.searchQuery = it + state.selectedIndex = 0 + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester), + placeholder = { Text("Search screens and workspaces...") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + ) + + if (state.awaitingHashtag) { + HashtagInputSection(state, onSelectScreen, onDismiss) + } else if (state.isSearching) { + Box(Modifier.weight(1f)) { + UnifiedSearchResults( + state = state, + filteredWs = filteredWs, + workspaceManager = workspaceManager, + onSwitchWorkspace = onSwitchWorkspace, + onSelectScreen = onSelectScreen, + openColumnTypes = openColumnTypes, + pinnedNavBarState = pinnedNavBarState, + onDismiss = onDismiss, + ) + } + } else { + // Tab header + PrimaryTabRow(selectedTabIndex = state.activeTab.ordinal) { + AppDrawerTab.entries.forEach { tab -> + Tab( + selected = state.activeTab == tab, + onClick = { state.switchTab(tab) }, + text = { + Text( + tab.name.lowercase().replaceFirstChar { it.uppercase() }, + ) + }, + ) + } + } + + Box(Modifier.weight(1f)) { + when (state.activeTab) { + AppDrawerTab.SCREENS -> { + DrawerGrid( + state, + openColumnTypes, + pinnedNavBarState, + onSelectScreen, + onDismiss, + ) + } + + AppDrawerTab.WORKSPACES -> { + WorkspacesGrid( + workspaceManager = workspaceManager, + onSwitchWorkspace = { + onSwitchWorkspace(it) + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun DrawerGrid( + state: AppDrawerState, + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val listState = rememberLazyListState() + + LaunchedEffect(state.selectedIndex) { + val approxItem = (state.selectedIndex / 4).coerceAtLeast(0) + if (approxItem < listState.layoutInfo.totalItemsCount) { + listState.animateScrollToItem(approxItem) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.padding(top = 8.dp), + ) { + var globalIndex = 0 + state.groupedScreens.forEach { (category, screens) -> + stickyHeader(key = "header-${category.name}") { + CategoryHeader(category) + } + val startIndex = globalIndex + item(key = "grid-${category.name}") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + screens.forEachIndexed { localIdx, screen -> + DrawerScreenCard( + type = screen, + isSelected = (startIndex + localIdx) == state.selectedIndex, + isOpen = openColumnTypes.contains(screen.typeKey()), + isPinned = pinnedNavBarState.isPinned(screen), + onClick = { state.select(screen, onSelectScreen, onDismiss) }, + onTogglePin = { + if (pinnedNavBarState.isPinned(screen)) { + pinnedNavBarState.unpin(screen) + } else { + pinnedNavBarState.pin(screen) + } + }, + onHover = { state.selectedIndex = startIndex + localIdx }, + ) + } + } + } + globalIndex += screens.size + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawerScreenCard( + type: DeckColumnType, + isSelected: Boolean, + isOpen: Boolean, + isPinned: Boolean, + onClick: () -> Unit, + onTogglePin: () -> Unit, + onHover: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + Box { + Surface( + modifier = + Modifier + .size(80.dp) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { onHover() } + .onPointerEvent(PointerEventType.Press) { event -> + if (event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { + showMenu = true + } + }, + shape = RoundedCornerShape(12.dp), + tonalElevation = if (isSelected) 8.dp else 2.dp, + color = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + type.icon(), + contentDescription = type.title(), + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.height(4.dp)) + Text( + type.title(), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } + // Open indicator (top-end dot) + if (isOpen) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(6.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } + // Pinned indicator (top-start dot) + if (isPinned) { + Box( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .size(6.dp) + .background(MaterialTheme.colorScheme.tertiary, CircleShape), + ) + } + } + } + + // Context menu for pin/unpin + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + if (PinnedNavBarState.isPinnable(type)) { + DropdownMenuItem( + text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") }, + onClick = { + onTogglePin() + showMenu = false + }, + leadingIcon = { + Icon( + Icons.Default.PushPin, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } +} + +@Composable +private fun CategoryHeader(category: ScreenCategory) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(category.icon, category.title, Modifier.size(16.dp)) + Text( + category.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun HashtagInputSection( + state: AppDrawerState, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val hashtagFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(50) + hashtagFocusRequester.requestFocus() + } + + Column(Modifier.padding(16.dp)) { + Text("Enter hashtag:", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.hashtagInput, + onValueChange = { state.hashtagInput = it.removePrefix("#") }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(hashtagFocusRequester), + placeholder = { Text("bitcoin, nostr...") }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { state.awaitingHashtag = false }) { Text("Back") } + Button( + onClick = { state.confirmHashtag(onSelectScreen, onDismiss) }, + enabled = state.hashtagInput.isNotBlank(), + ) { + Text("Open") + } + } + } +} + +// -- Workspaces Tab -- + +@Composable +private fun WorkspacesGrid( + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, +) { + val workspaces by workspaceManager.workspaces.collectAsState() + val activeIndex by workspaceManager.activeIndex.collectAsState() + var showEditor by remember { mutableStateOf(false) } + var editTarget by remember { mutableStateOf(null) } + + LazyColumn(Modifier.padding(8.dp)) { + itemsIndexed(workspaces) { index, ws -> + WorkspaceCard( + workspace = ws, + isActive = index == activeIndex, + onSwitch = { + val switched = workspaceManager.switchTo(index) + if (switched != null) { + onSwitchWorkspace(switched) + } + }, + onEdit = { + editTarget = ws + showEditor = true + }, + onDelete = { + val wasActive = index == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, + canDelete = workspaces.size > 1, + ) + } + // "+" card + item { + AddWorkspaceCard( + onClick = { + editTarget = null + showEditor = true + }, + enabled = workspaces.size < WorkspaceManager.MAX_WORKSPACES, + ) + } + } + + if (showEditor) { + WorkspaceEditorDialog( + initial = editTarget, + onSave = { ws -> + if (editTarget != null) { + workspaceManager.updateWorkspace(ws) + } else { + workspaceManager.addWorkspace(ws) + } + showEditor = false + }, + onDismiss = { showEditor = false }, + ) + } +} + +@Composable +private fun WorkspaceCard( + workspace: Workspace, + isActive: Boolean, + onSwitch: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + canDelete: Boolean, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(onClick = onSwitch), + shape = RoundedCornerShape(12.dp), + tonalElevation = if (isActive) 8.dp else 2.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + WorkspaceIcons.resolve(workspace.iconName), + workspace.name, + Modifier.size(28.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(workspace.name, style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // Layout mode badge — non-interactive Surface+Text + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + if (workspace.layoutMode == LayoutMode.DECK) "Deck" else "Single", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + // Column preview with display names + Text( + workspace.columns.joinToString(", ") { col -> + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() + ?: col.typeKey + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Active indicator + if (isActive) { + Spacer(Modifier.width(8.dp)) + Icon( + Icons.Default.Check, + "Active", + Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, "Edit", Modifier.size(18.dp)) + } + IconButton(onClick = onDelete, enabled = canDelete) { + Icon(Icons.Default.Delete, "Delete", Modifier.size(18.dp)) + } + } + } +} + +@Composable +private fun AddWorkspaceCard( + onClick: () -> Unit, + enabled: Boolean, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(12.dp), + tonalElevation = 1.dp, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Add, "Add workspace", Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text( + "New Workspace", + style = MaterialTheme.typography.titleSmall, + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } +} + +// -- Workspace Editor Dialog -- + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun WorkspaceEditorDialog( + initial: Workspace?, + onSave: (Workspace) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(initial?.name ?: "") } + var iconName by remember { mutableStateOf(initial?.iconName ?: "Home") } + var layoutMode by remember { mutableStateOf(initial?.layoutMode ?: LayoutMode.DECK) } + var columns by remember { + mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home"))) + } + var singlePaneScreens by remember { + mutableStateOf(initial?.singlePaneScreens ?: listOf("home")) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (initial != null) "Edit Workspace" else "New Workspace") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + // Icon picker + Text("Icon", style = MaterialTheme.typography.labelMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isSelected = { iName: String -> iName == iconName } + WorkspaceIcons.availableNames.forEach { iName -> + Surface( + modifier = Modifier.size(40.dp).clickable { iconName = iName }, + shape = RoundedCornerShape(8.dp), + color = + if (isSelected(iName)) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + tonalElevation = if (isSelected(iName)) 4.dp else 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + WorkspaceIcons.resolve(iName), + iName, + modifier = Modifier.size(24.dp), + tint = + if (isSelected(iName)) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + // Layout mode toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Layout:", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.width(8.dp)) + SingleChoiceSegmentedButtonRow { + SegmentedButton( + selected = layoutMode == LayoutMode.SINGLE_PANE, + onClick = { layoutMode = LayoutMode.SINGLE_PANE }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { + Text("Single", maxLines = 1) + } + SegmentedButton( + selected = layoutMode == LayoutMode.DECK, + onClick = { layoutMode = LayoutMode.DECK }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { + Text("Deck") + } + } + } + // Deck: column list + if (layoutMode == LayoutMode.DECK) { + DeckColumnEditor(columns = columns, onColumnsChange = { columns = it }) + } + // Single Pane: nav bar screen selector (first = default) + if (layoutMode == LayoutMode.SINGLE_PANE) { + SinglePaneScreensEditor( + screens = singlePaneScreens, + onScreensChange = { singlePaneScreens = it }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSave( + Workspace( + id = + initial?.id ?: java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Untitled" }, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreens = singlePaneScreens, + ), + ) + }, + enabled = name.isNotBlank(), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun DeckColumnEditor( + columns: List, + onColumnsChange: (List) -> Unit, +) { + Column { + Text("Columns", style = MaterialTheme.typography.labelMedium) + columns.forEachIndexed { idx, col -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() ?: col.typeKey, + Modifier.weight(1f), + ) + IconButton( + onClick = { + if (columns.size > 1) { + onColumnsChange(columns.toMutableList().apply { removeAt(idx) }) + } + }, + enabled = columns.size > 1, + ) { + Icon(Icons.Default.Close, "Remove") + } + } + } + // Add column dropdown + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text("+ Add Column") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onColumnsChange(columns + Workspace.WorkspaceColumn(screen.typeKey())) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun SinglePaneScreensEditor( + screens: List, + onScreensChange: (List) -> Unit, +) { + Column { + Text("Nav Bar Screens", style = MaterialTheme.typography.labelMedium) + Text( + "First screen is the default", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + screens.forEachIndexed { idx, key -> + val displayName = DeckState.parseColumnTypeFromKey(key)?.title() ?: key + Row(verticalAlignment = Alignment.CenterVertically) { + if (idx == 0) { + Icon( + Icons.Default.Star, + "Default", + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + } + Text(displayName, Modifier.weight(1f)) + IconButton(onClick = { + onScreensChange(screens.toMutableList().apply { removeAt(idx) }) + }) { Icon(Icons.Default.Close, "Remove", Modifier.size(16.dp)) } + } + } + // Add screen dropdown + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text("+ Add Screen") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PinnedNavBarState.PINNABLE_SCREENS + .filter { screen -> screens.none { it == screen.typeKey() } } + .forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onScreensChange(screens + screen.typeKey()) + expanded = false + }, + ) + } + } + } +} + +// -- Unified Search Results -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun UnifiedSearchResults( + state: AppDrawerState, + filteredWs: List, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, + onSelectScreen: (DeckColumnType) -> Unit, + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + onDismiss: () -> Unit, +) { + val activeIndex by workspaceManager.activeIndex.collectAsState() + val allWorkspaces by workspaceManager.workspaces.collectAsState() + + LazyColumn(Modifier.padding(8.dp)) { + // Workspace results first + if (filteredWs.isNotEmpty()) { + stickyHeader { + Text( + "Workspaces", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + itemsIndexed(filteredWs) { idx, ws -> + val wsIdx = allWorkspaces.indexOf(ws) + val isHighlighted = idx == state.selectedIndex + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + tonalElevation = if (isHighlighted) 8.dp else 0.dp, + ) { + WorkspaceCard( + workspace = ws, + isActive = wsIdx == activeIndex, + onSwitch = { + val switched = workspaceManager.switchTo(wsIdx) + if (switched != null) { + onSwitchWorkspace(switched) + onDismiss() + } + }, + onEdit = { /* edit via Workspaces tab */ }, + onDelete = { + val wasActive = wsIdx == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, + canDelete = allWorkspaces.size > 1, + ) + } + } + } + // Screen results below + if (state.filteredScreens.isNotEmpty()) { + stickyHeader { + Text( + "Screens", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), + ) { + val wsOffset = filteredWs.size + state.filteredScreens.forEachIndexed { localIdx, screen -> + DrawerScreenCard( + type = screen, + isSelected = (wsOffset + localIdx) == state.selectedIndex, + isOpen = openColumnTypes.contains(screen.typeKey()), + isPinned = pinnedNavBarState.isPinned(screen), + onClick = { state.select(screen, onSelectScreen, onDismiss) }, + onTogglePin = { + if (pinnedNavBarState.isPinned(screen)) { + pinnedNavBarState.unpin(screen) + } else { + pinnedNavBarState.pin(screen) + } + }, + onHover = { state.selectedIndex = wsOffset + localIdx }, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index bdfb402983..653042d2aa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -221,13 +221,7 @@ class DeckState( "id" to col.id, "type" to col.type.typeKey(), "width" to col.width, - "param" to - when (col.type) { - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - is DeckColumnType.Hashtag -> col.type.tag - else -> null - }, + "param" to col.type.param(), ) } DesktopPreferences.deckColumns = mapper.writeValueAsString(data) @@ -256,6 +250,20 @@ class DeckState( } } + fun loadFromWorkspace(workspaceColumns: List) { + val loaded = + workspaceColumns.mapNotNull { col -> + val entry = mapOf("type" to col.typeKey, "param" to col.param) + val type = parseColumnType(entry) ?: return@mapNotNull null + DeckColumn( + type = type, + width = col.width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH), + ) + } + _columns.value = loaded.ifEmpty { DEFAULT_COLUMNS } + _focusedColumnIndex.value = 0 + } + companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f @@ -271,6 +279,11 @@ class DeckState( private val mapper = jacksonObjectMapper() + fun parseColumnTypeFromKey( + typeKey: String, + param: String? = null, + ): DeckColumnType? = parseColumnType(mapOf("type" to typeKey, "param" to param)) + private fun parseColumnType(entry: Map): DeckColumnType? { val typeKey = entry["type"] as? String ?: return null val param = entry["param"] as? String @@ -285,6 +298,10 @@ class DeckState( "my_profile" -> DeckColumnType.MyProfile "chess" -> DeckColumnType.Chess "settings" -> DeckColumnType.Settings + "drafts" -> DeckColumnType.Drafts + "highlights" -> DeckColumnType.MyHighlights + "editor" -> DeckColumnType.Editor(param) + "article" -> param?.let { DeckColumnType.Article(it) } "profile" -> param?.let { DeckColumnType.Profile(it) } "thread" -> param?.let { DeckColumnType.Thread(it) } "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt new file mode 100644 index 0000000000..6deb120b71 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt @@ -0,0 +1,114 @@ +/* + * 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.ui.deck + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Manages which screens are pinned to the navigation sidebar. + * Pin/unpin syncs to the active workspace's singlePaneScreens. + */ +class PinnedNavBarState( + private val workspaceManager: WorkspaceManager? = null, +) { + private val _pinnedScreens = MutableStateFlow(DEFAULT_PINNED) + val pinnedScreens: StateFlow> = _pinnedScreens.asStateFlow() + + fun isPinned(type: DeckColumnType): Boolean = _pinnedScreens.value.any { it.typeKey() == type.typeKey() } + + fun pin(type: DeckColumnType) { + if (isPinned(type)) return + if (!isPinnable(type)) return + _pinnedScreens.update { it + type } + syncToWorkspace() + } + + fun unpin(type: DeckColumnType) { + if (!isUnpinnable(type)) return + _pinnedScreens.update { current -> current.filter { it.typeKey() != type.typeKey() } } + syncToWorkspace() + } + + fun move( + fromIndex: Int, + toIndex: Int, + ) { + _pinnedScreens.update { current -> + if (fromIndex !in current.indices || toIndex !in current.indices) return@update current + val mutable = current.toMutableList() + val item = mutable.removeAt(fromIndex) + mutable.add(toIndex, item) + mutable.toList() + } + syncToWorkspace() + } + + fun loadFromList(screens: List) { + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + } + + fun loadFromWorkspace() { + val ws = workspaceManager?.activeWorkspace ?: return + if (ws.singlePaneScreens.isNotEmpty()) { + val screens = + ws.singlePaneScreens.mapNotNull { key -> + PINNABLE_SCREENS.find { it.typeKey() == key } + } + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + } + } + + private fun syncToWorkspace() { + val wm = workspaceManager ?: return + val ws = wm.activeWorkspace + val updated = ws.copy(singlePaneScreens = _pinnedScreens.value.map { it.typeKey() }) + wm.updateWorkspace(updated) + } + + companion object { + val PINNABLE_SCREENS: List = + LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor } + + val DEFAULT_PINNED: List = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.MyHighlights, + DeckColumnType.Search, + DeckColumnType.Bookmarks, + DeckColumnType.Messages, + DeckColumnType.Notifications, + DeckColumnType.MyProfile, + DeckColumnType.Chess, + DeckColumnType.Settings, + ) + + private val ALWAYS_PINNED = setOf("home", "settings") + + fun isPinnable(type: DeckColumnType): Boolean = PINNABLE_SCREENS.any { it.typeKey() == type.typeKey() } + + fun isUnpinnable(type: DeckColumnType): Boolean = type.typeKey() !in ALWAYS_PINNED + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 5e25996277..873f38eab3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -30,15 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Apps import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail @@ -49,11 +41,8 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable 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.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState @@ -73,27 +62,6 @@ import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope -private data class NavItem( - val type: DeckColumnType, - val icon: ImageVector, - val label: String, -) - -private val navItems = - listOf( - NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), - NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), - NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"), - NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"), - NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), - NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), - NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), - NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"), - NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"), - NavItem(DeckColumnType.Chess, Icons.Default.Extension, "Chess"), - NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"), - ) - @Composable fun SinglePaneLayout( relayManager: DesktopRelayConnectionManager, @@ -106,6 +74,9 @@ fun SinglePaneLayout( highlightStore: DesktopHighlightStore, draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, + singlePaneState: SinglePaneState, + pinnedNavBarState: PinnedNavBarState, + onOpenAppDrawer: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, @@ -114,7 +85,7 @@ fun SinglePaneLayout( lastRelayEventAt: Long? = null, modifier: Modifier = Modifier, ) { - var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } + val currentColumnType by singlePaneState.currentScreen.collectAsState() val navState = remember { ColumnNavigationState() } val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() @@ -127,23 +98,24 @@ fun SinglePaneLayout( modifier = Modifier.width(80.dp).fillMaxHeight(), containerColor = MaterialTheme.colorScheme.surfaceVariant, ) { - navItems.forEach { item -> + val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() + pinnedScreens.forEach { screenType -> NavigationRailItem( - selected = currentColumnType == item.type && navStack.isEmpty(), + selected = currentColumnType == screenType && navStack.isEmpty(), onClick = { - currentColumnType = item.type + singlePaneState.navigate(screenType) navState.clear() }, icon = { Icon( - item.icon, - contentDescription = item.label, + screenType.icon(), + contentDescription = screenType.title(), modifier = Modifier.size(22.dp), ) }, label = { Text( - item.label, + screenType.title(), style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -152,6 +124,25 @@ fun SinglePaneLayout( ) } + NavigationRailItem( + selected = false, + onClick = onOpenAppDrawer, + icon = { + Icon( + Icons.Default.Apps, + contentDescription = "App Drawer", + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + "More", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + }, + ) + Spacer(Modifier.weight(1f)) // Relay health — shows elapsed time since last event (hidden when <30s) @@ -171,7 +162,7 @@ fun SinglePaneLayout( TorStatusIndicator( status = torState.status, onClick = { - currentColumnType = DeckColumnType.Settings + singlePaneState.navigate(DeckColumnType.Settings) navState.clear() }, modifier = Modifier.padding(bottom = 12.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt new file mode 100644 index 0000000000..9b4a69f3b8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt @@ -0,0 +1,37 @@ +/* + * 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.ui.deck + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Holds current screen for single-pane mode. Mirrors DeckState pattern. + */ +class SinglePaneState { + private val _currentScreen = MutableStateFlow(DeckColumnType.HomeFeed) + val currentScreen: StateFlow = _currentScreen.asStateFlow() + + fun navigate(type: DeckColumnType) { + _currentScreen.value = type + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt new file mode 100644 index 0000000000..0e2d2c62ab --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt @@ -0,0 +1,81 @@ +/* + * 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.ui.deck + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Work +import androidx.compose.ui.graphics.vector.ImageVector +import com.vitorpamplona.amethyst.desktop.LayoutMode + +data class Workspace( + val id: String = + java.util.UUID + .randomUUID() + .toString(), + val name: String, + val iconName: String, + val layoutMode: LayoutMode, + val columns: List, + val singlePaneScreens: List = emptyList(), +) { + data class WorkspaceColumn( + val typeKey: String, + val param: String? = null, + val width: Float = 400f, + ) +} + +object WorkspaceIcons { + private val icons: Map = + mapOf( + "Groups" to Icons.Default.Groups, + "Edit" to Icons.Default.Edit, + "MenuBook" to Icons.Default.MenuBook, + "Home" to Icons.Default.Home, + "Chat" to Icons.Default.Chat, + "Search" to Icons.Default.Search, + "SportsEsports" to Icons.Default.SportsEsports, + "Bookmark" to Icons.Default.Bookmark, + "Explore" to Icons.Default.Explore, + "Person" to Icons.Default.Person, + "Star" to Icons.Default.Star, + "Favorite" to Icons.Default.Favorite, + "Work" to Icons.Default.Work, + "Code" to Icons.Default.Code, + ) + + val availableNames: List = icons.keys.sorted() + + fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt new file mode 100644 index 0000000000..2de3e9cf49 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -0,0 +1,227 @@ +/* + * 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.ui.deck + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.LayoutMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class WorkspaceManager( + private val saveScope: CoroutineScope, +) { + private val _workspaces = MutableStateFlow(listOf(DEFAULT_WORKSPACE)) + val workspaces: StateFlow> = _workspaces.asStateFlow() + + private val _activeIndex = MutableStateFlow(0) + val activeIndex: StateFlow = _activeIndex.asStateFlow() + + val activeWorkspace: Workspace + get() = _workspaces.value.getOrElse(_activeIndex.value) { _workspaces.value.first() } + + private var saveJob: Job? = null + + fun switchTo(index: Int): Workspace? { + if (index !in _workspaces.value.indices) return null + _activeIndex.value = index + scheduleSave() + return activeWorkspace + } + + fun saveCurrentColumns(columns: List) { + _workspaces.update { wsList -> + wsList.mapIndexed { idx, ws -> + if (idx == _activeIndex.value) { + ws.copy( + columns = + columns.map { col -> + Workspace.WorkspaceColumn( + typeKey = col.type.typeKey(), + param = col.type.param(), + width = col.width, + ) + }, + ) + } else { + ws + } + } + } + scheduleSave() + } + + fun addWorkspace(workspace: Workspace) { + if (_workspaces.value.size >= MAX_WORKSPACES) return + _workspaces.update { it + workspace } + scheduleSave() + } + + fun updateWorkspace(workspace: Workspace) { + _workspaces.update { wsList -> + wsList.map { if (it.id == workspace.id) workspace else it } + } + scheduleSave() + } + + fun deleteWorkspace(id: String) { + if (_workspaces.value.size <= 1) return + val deletedIdx = _workspaces.value.indexOfFirst { it.id == id } + if (deletedIdx < 0) return + _workspaces.update { it.filter { ws -> ws.id != id } } + _activeIndex.value = + when { + deletedIdx < _activeIndex.value -> _activeIndex.value - 1 + deletedIdx == _activeIndex.value -> 0 + else -> _activeIndex.value + }.coerceIn(_workspaces.value.indices) + scheduleSave() + } + + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + + fun save() { + try { + val data = + mapOf( + "activeIndex" to _activeIndex.value, + "workspaces" to + _workspaces.value.map { ws -> + mapOf( + "id" to ws.id, + "name" to ws.name, + "iconName" to ws.iconName, + "layoutMode" to ws.layoutMode.name, + "singlePaneScreens" to ws.singlePaneScreens, + "columns" to + ws.columns.map { col -> + mapOf( + "typeKey" to col.typeKey, + "param" to col.param, + "width" to col.width, + ) + }, + ) + }, + ) + DesktopPreferences.workspaces = mapper.writeValueAsString(data) + } catch (e: Exception) { + println("WorkspaceManager: failed to save: ${e.message}") + } + } + + fun load() { + try { + val json = DesktopPreferences.workspaces + if (json.isBlank()) return + val data: Map = mapper.readValue(json) + val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0 + + @Suppress("UNCHECKED_CAST") + val wsList = data["workspaces"] as? List> ?: return + val loaded = + wsList.mapNotNull { entry -> + try { + val name = entry["name"] as? String ?: return@mapNotNull null + val iconName = entry["iconName"] as? String ?: "Home" + val layoutMode = + try { + LayoutMode.valueOf(entry["layoutMode"] as? String ?: "DECK") + } catch (e: Exception) { + LayoutMode.DECK + } + + @Suppress("UNCHECKED_CAST") + val singlePaneScreens = + (entry["singlePaneScreens"] as? List) ?: run { + // Backward compat: old format had single "singlePaneScreen" string + val legacy = entry["singlePaneScreen"] as? String + if (legacy != null) listOf(legacy) else emptyList() + } + + @Suppress("UNCHECKED_CAST") + val columns = + (entry["columns"] as? List>)?.map { col -> + Workspace.WorkspaceColumn( + typeKey = col["typeKey"] as? String ?: "home", + param = col["param"] as? String, + width = (col["width"] as? Number)?.toFloat() ?: 400f, + ) + } ?: emptyList() + + Workspace( + id = + entry["id"] as? String ?: java.util.UUID + .randomUUID() + .toString(), + name = name, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreens = singlePaneScreens, + ) + } catch (e: Exception) { + null + } + } + if (loaded.isNotEmpty()) { + _workspaces.value = loaded + _activeIndex.value = activeIdx.coerceIn(loaded.indices) + } + } catch (e: Exception) { + println("WorkspaceManager: failed to load: ${e.message}") + } + } + + companion object { + const val MAX_WORKSPACES = 9 + private const val SAVE_DEBOUNCE_MS = 500L + private val mapper = jacksonObjectMapper() + + val DEFAULT_WORKSPACE = + Workspace( + id = "default-social", + name = "Social", + iconName = "Groups", + layoutMode = LayoutMode.DECK, + columns = + listOf( + Workspace.WorkspaceColumn("home"), + Workspace.WorkspaceColumn("notifications"), + Workspace.WorkspaceColumn("messages"), + ), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index ec77fae663..80ca91929b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -40,6 +40,7 @@ class AddressSerializer { } fun parse(addressId: String): Address? { + if (addressId.length < 66) return null if (addressId.isBlank()) return null return try { val parts = addressId.split(":", limit = 3) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt new file mode 100644 index 0000000000..d4b673582b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FavoriteAlgoFeedsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavoriteAlgoFeeds(): List = tags.mapNotNull(AddressBookmark::parse) + + suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + + companion object { + const val KIND = 10090 + const val ALT = "Favorite algo-feeds list" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + feed: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent = + if (isPrivate) { + create( + publicFeeds = emptyList(), + privateFeeds = listOf(feed), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicFeeds = listOf(feed), + privateFeeds = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent { + val idOnly = AddressBookmark.assemble(feed, null) + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent { + val template = build(publicFeeds, privateFeeds, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = + PrivateTagsInContent.encryptNip44( + privateFeeds.map { it.toTagArray() }.toTypedArray(), + signer, + ), + createdAt = createdAt, + ) { + alt(ALT) + favoriteAlgoFeeds(publicFeeds) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4214d7627a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArrayBuilder.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray()) + +fun TagArrayBuilder.favoriteAlgoFeeds(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt new file mode 100644 index 0000000000..5024bb91e3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress) + +fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2ebafdbadc..8c46307f20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -422,6 +423,7 @@ class EventFactory { GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig) HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt new file mode 100644 index 0000000000..f3ac5ff24b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FavoriteAlgoFeedsListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + private fun dvm( + pubkey: String, + dTag: String = "content-discovery", + ) = AddressBookmark(Address(31990, pubkey, dTag)) + + @Test + fun kindMatchesSpec() { + assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND) + } + + @Test + fun addressesAreReplaceableWithFixedDTag() { + val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64)) + assertEquals(10090, address.kind) + assertEquals("", address.dTag) + } + + @Test + fun createStoresDvmAsATag() = + runTest { + val aFeed = dvm("a".repeat(64)) + + val event = + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + assertEquals(10090, event.kind) + assertTrue( + event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == aFeed.address.toValue() }, + "public a tag for the favourited DVM should be present", + ) + val favorites = event.publicFavoriteAlgoFeeds() + assertEquals(1, favorites.size) + assertEquals(aFeed.address, favorites.first().address) + } + + @Test + fun addAppendsWithoutDuplicatingExistingEntry() = + runTest { + val aFeed = dvm("a".repeat(64)) + + val initial = + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val afterDupeAdd = + FavoriteAlgoFeedsListEvent.add( + earlierVersion = initial, + feed = aFeed, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + assertEquals( + 1, + afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address }, + "re-adding the same DVM must not produce a duplicate tag", + ) + } + + @Test + fun addPreservesOtherFavorites() = + runTest { + val first = dvm("a".repeat(64)) + val second = dvm("b".repeat(64)) + + val initial = + FavoriteAlgoFeedsListEvent.create( + feed = first, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteAlgoFeedsListEvent.add( + earlierVersion = initial, + feed = second, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() + assertTrue(first.address in addresses) + assertTrue(second.address in addresses) + } + + @Test + fun removeDropsTheRequestedDvmOnly() = + runTest { + val first = dvm("a".repeat(64)) + val second = dvm("b".repeat(64)) + + val initial = + FavoriteAlgoFeedsListEvent.create( + publicFeeds = listOf(first, second), + privateFeeds = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteAlgoFeedsListEvent.remove( + earlierVersion = initial, + feed = first.address, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() + assertFalse(first.address in addresses, "removed DVM should not survive") + assertTrue(second.address in addresses, "other DVMs should be preserved") + } +}