mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge branch 'main' into claude/badge-system-amethyst-J07UM
This commit is contained in:
@@ -173,3 +173,5 @@ packaging/appimage/squashfs-root/
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
benchmark/src/main/jniLibs/
|
||||
|
||||
/tools/marmot-interop/state
|
||||
|
||||
@@ -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<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
val event = bookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return
|
||||
sendMyPublicAndPrivateOutbox(event)
|
||||
}
|
||||
|
||||
suspend fun removeDeletedOldBookmarks(
|
||||
deletedEventIds: Set<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
) {
|
||||
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<Note>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val event = pinState.removeDeletedPins(deletedNotes) ?: return
|
||||
sendMyPublicAndPrivateOutbox(event)
|
||||
}
|
||||
|
||||
suspend fun createAddPinEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
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<NormalizedRelayUrl>) -> Unit,
|
||||
) {
|
||||
val relays = nip65RelayList.inboxFlow.value.toSet()
|
||||
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.algoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
private const val RESPONSE_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a favorite algo feed's current request/response state.
|
||||
*
|
||||
* - [requestId] is the id of the most recently published kind-5300 request.
|
||||
* - [responseRelays] is the relay set the kind-5300 was sent to — the same set on
|
||||
* which the DVM will publish its 6300/7000 responses, so the home subscription
|
||||
* manager must listen there (not on the user's own outbox).
|
||||
* - [ids] and [addresses] are the note references returned by the latest kind-6300 response.
|
||||
* - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …).
|
||||
* - [errorMessage] captures any client-side failure while publishing the request.
|
||||
*/
|
||||
data class FavoriteAlgoFeedsSnapshot(
|
||||
val requestId: HexKey? = null,
|
||||
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
val ids: Set<HexKey> = emptySet(),
|
||||
val addresses: Set<String> = emptySet(),
|
||||
val latestStatus: NIP90StatusEvent? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages the NIP-90 content-discovery RPC cycle for each favorite algo feed the user
|
||||
* pins to the top-nav.
|
||||
*
|
||||
* The orchestrator is lazy: it starts a request/response cycle the first time any
|
||||
* consumer calls [observe] for a given DVM address, and keeps emitting updated
|
||||
* snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the
|
||||
* kind-5300 request (e.g. pull-to-refresh).
|
||||
*
|
||||
* This class does not own the relay subscriptions that fetch DVM responses and
|
||||
* matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the
|
||||
* user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes
|
||||
* what the relays deliver into `LocalCache`.
|
||||
*/
|
||||
class FavoriteAlgoFeedsOrchestrator(
|
||||
val account: Account,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private val flows = mutableMapOf<Address, MutableStateFlow<FavoriteAlgoFeedsSnapshot>>()
|
||||
private val jobs = mutableMapOf<Address, Job>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun observe(feedAddress: Address): StateFlow<FavoriteAlgoFeedsSnapshot> {
|
||||
flows[feedAddress]?.let { return it.asStateFlow() }
|
||||
|
||||
val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot())
|
||||
flows[feedAddress] = seed
|
||||
scope.launch { startFor(feedAddress, seed) }
|
||||
return seed.asStateFlow()
|
||||
}
|
||||
|
||||
fun refresh(feedAddress: Address) {
|
||||
val seed = flows[feedAddress] ?: return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
jobs.remove(feedAddress)?.cancel()
|
||||
}
|
||||
startFor(feedAddress, seed)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(feedAddress: Address) {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
jobs.remove(feedAddress)?.cancel()
|
||||
flows.remove(feedAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startFor(
|
||||
feedAddress: Address,
|
||||
seed: MutableStateFlow<FavoriteAlgoFeedsSnapshot>,
|
||||
) {
|
||||
val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return
|
||||
val job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
account.requestDVMContentDiscovery(user) { request, relays ->
|
||||
seed.update {
|
||||
it.copy(
|
||||
requestId = request.id,
|
||||
responseRelays = relays,
|
||||
ids = emptySet(),
|
||||
addresses = emptySet(),
|
||||
latestStatus = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val requestId = seed.value.requestId ?: return@launch
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
).collectLatest { response ->
|
||||
if (response == null) return@collectLatest
|
||||
val (eventIds, addresses) = splitInnerTags(response.innerTags())
|
||||
seed.update {
|
||||
it.copy(
|
||||
ids = eventIds,
|
||||
addresses = addresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90StatusEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90StatusEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
).collectLatest { status ->
|
||||
seed.update { it.copy(latestStatus = status) }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing arrives within RESPONSE_TIMEOUT_MS (neither a 6300
|
||||
// response nor any 7000 status), surface an error so the banner
|
||||
// can show Retry instead of spinning forever.
|
||||
launch {
|
||||
delay(RESPONSE_TIMEOUT_MS)
|
||||
val current = seed.value
|
||||
val stillWaiting =
|
||||
current.requestId == requestId &&
|
||||
current.ids.isEmpty() &&
|
||||
current.addresses.isEmpty() &&
|
||||
current.latestStatus == null &&
|
||||
current.errorMessage == null
|
||||
if (stillWaiting) {
|
||||
seed.update { it.copy(errorMessage = "timeout") }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e)
|
||||
seed.update { it.copy(errorMessage = e.message ?: "Unknown error") }
|
||||
}
|
||||
}
|
||||
|
||||
mutex.withLock { jobs[feedAddress] = job }
|
||||
}
|
||||
|
||||
private fun splitInnerTags(innerTags: List<HexKey>): Pair<Set<HexKey>, Set<String>> {
|
||||
val ids = mutableSetOf<HexKey>()
|
||||
val addresses = mutableSetOf<String>()
|
||||
innerTags.forEach { value ->
|
||||
if (value.contains(':')) {
|
||||
addresses.add(value)
|
||||
} else if (value.length == 64) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
return ids to addresses
|
||||
}
|
||||
}
|
||||
@@ -127,4 +127,21 @@ class PinListState(
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeDeletedPins(deletedNotes: Set<Note>): 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)
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet
|
||||
|
||||
class FavoriteAlgoFeedsListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteAlgoFeedsListEvent>(signer)
|
||||
|
||||
fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet()
|
||||
|
||||
suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet()
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FavoriteAlgoFeedsListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: FavoriteAlgoFeedsListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress())
|
||||
|
||||
fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getFavoriteAlgoFeedsListFlow(): StateFlow<NoteState> = favoriteAlgoFeedsListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent
|
||||
|
||||
suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set<Address> {
|
||||
val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList
|
||||
return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<Address>> =
|
||||
getFavoriteAlgoFeedsListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(favoriteAlgoFeedsListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flowNotes: StateFlow<List<AddressableNote>> =
|
||||
flow
|
||||
.map { addresses ->
|
||||
addresses.map { cache.getOrCreateAddressableNote(it) }
|
||||
}.onStart {
|
||||
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent {
|
||||
val list = getFavoriteAlgoFeedsList()
|
||||
return if (list == null) {
|
||||
FavoriteAlgoFeedsListEvent.create(dvm, false, signer)
|
||||
} else {
|
||||
FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? {
|
||||
val list = getFavoriteAlgoFeedsList() ?: return null
|
||||
return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer)
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupFavoriteAlgoFeedsList?.let { event ->
|
||||
Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start")
|
||||
getFavoriteAlgoFeedsListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" }
|
||||
(it.note.event as? FavoriteAlgoFeedsListEvent)?.let {
|
||||
settings.updateFavoriteAlgoFeedsListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
|
||||
@@ -30,11 +31,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavoriteAlgoFeedsFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -62,6 +66,8 @@ class FeedTopNavFilterState(
|
||||
val caches: FeedDecryptionCaches,
|
||||
val signer: NostrSigner,
|
||||
val scope: CoroutineScope,
|
||||
val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
|
||||
) {
|
||||
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
|
||||
when (listName) {
|
||||
@@ -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)
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
||||
/**
|
||||
* Feed flow that merges snapshots from every currently-favorited DVM into a
|
||||
* single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the
|
||||
* favorite set changes.
|
||||
*/
|
||||
class AllFavoriteAlgoFeedsFlow(
|
||||
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
|
||||
val orchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
) : IFeedFlowsType {
|
||||
private fun resolveContentRelays(
|
||||
outbox: Set<NormalizedRelayUrl>,
|
||||
proxy: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
|
||||
|
||||
private fun merge(
|
||||
snapshots: List<FavoriteAlgoFeedsSnapshot>,
|
||||
contentRelays: Set<NormalizedRelayUrl>,
|
||||
): AllFavoriteAlgoFeedsTopNavFilter {
|
||||
val ids = mutableSetOf<String>()
|
||||
val addresses = mutableSetOf<String>()
|
||||
val listen = mutableSetOf<NormalizedRelayUrl>()
|
||||
val requestIds = mutableSetOf<String>()
|
||||
snapshots.forEach { snap ->
|
||||
ids += snap.ids
|
||||
addresses += snap.addresses
|
||||
listen += snap.responseRelays
|
||||
snap.requestId?.let { requestIds += it }
|
||||
}
|
||||
return AllFavoriteAlgoFeedsTopNavFilter(
|
||||
acceptedIds = ids,
|
||||
acceptedAddresses = addresses,
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = listen,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun emptyFilter(contentRelays: Set<NormalizedRelayUrl>): AllFavoriteAlgoFeedsTopNavFilter =
|
||||
AllFavoriteAlgoFeedsTopNavFilter(
|
||||
acceptedIds = emptySet(),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = emptySet(),
|
||||
requestIds = emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun flow(): Flow<IFeedTopNavFilter> =
|
||||
favoriteAlgoFeedAddresses.flatMapLatest { addresses ->
|
||||
if (addresses.isEmpty()) {
|
||||
combine(outboxRelays, proxyRelays) { outbox, proxy ->
|
||||
emptyFilter(resolveContentRelays(outbox, proxy))
|
||||
}
|
||||
} else {
|
||||
val snapshotFlows: List<Flow<FavoriteAlgoFeedsSnapshot>> = addresses.map { orchestrator.observe(it) }
|
||||
combine(snapshotFlows) { it.toList() }
|
||||
.let { merged ->
|
||||
combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy ->
|
||||
merge(snaps, resolveContentRelays(outbox, proxy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter {
|
||||
val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value)
|
||||
val addresses = favoriteAlgoFeedAddresses.value
|
||||
return if (addresses.isEmpty()) {
|
||||
emptyFilter(contentRelays)
|
||||
} else {
|
||||
merge(addresses.map { orchestrator.observe(it).value }, contentRelays)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
|
||||
collector.emit(startValue())
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Top-nav filter that unions the latest kind-6300 responses from every currently
|
||||
* favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check
|
||||
* against a snapshot), but the accepted set is the union across N DVMs and the
|
||||
* request-id list carries one entry per DVM for the relay-listen subscription.
|
||||
*/
|
||||
@Immutable
|
||||
class AllFavoriteAlgoFeedsTopNavFilter(
|
||||
val acceptedIds: Set<HexKey>,
|
||||
val acceptedAddresses: Set<String>,
|
||||
val contentRelays: Set<NormalizedRelayUrl>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestIds: Set<HexKey>,
|
||||
) : IFeedTopNavFilter {
|
||||
override fun matchAuthor(pubkey: HexKey): Boolean = true
|
||||
|
||||
override fun match(noteEvent: Event): Boolean =
|
||||
noteEvent.id in acceptedIds ||
|
||||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
|
||||
|
||||
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
|
||||
|
||||
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
contentRelays.associateWith {
|
||||
FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
ids = acceptedIds,
|
||||
addresses = acceptedAddresses,
|
||||
)
|
||||
},
|
||||
listenRelays = listenRelays,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
class FavoriteAlgoFeedFlow(
|
||||
val feedAddress: Address,
|
||||
val orchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
) : IFeedFlowsType {
|
||||
private fun resolveRelays(
|
||||
outbox: Set<NormalizedRelayUrl>,
|
||||
proxy: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
|
||||
|
||||
private fun buildFilter(
|
||||
snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot,
|
||||
contentRelays: Set<NormalizedRelayUrl>,
|
||||
) = FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = feedAddress,
|
||||
acceptedIds = snapshot.ids,
|
||||
acceptedAddresses = snapshot.addresses,
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = snapshot.responseRelays,
|
||||
requestId = snapshot.requestId,
|
||||
)
|
||||
|
||||
override fun flow(): Flow<IFeedTopNavFilter> =
|
||||
combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy ->
|
||||
buildFilter(snap, resolveRelays(outbox, proxy))
|
||||
}
|
||||
|
||||
override fun startValue(): FavoriteAlgoFeedTopNavFilter =
|
||||
buildFilter(
|
||||
snapshot = orchestrator.observe(feedAddress).value,
|
||||
contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
|
||||
)
|
||||
|
||||
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
|
||||
collector.emit(startValue())
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Top-nav filter backed by the latest kind-6300 response from a favorite algo feed.
|
||||
*
|
||||
* The filter is a pure immutable membership check: [match] accepts a note only if the
|
||||
* DVM's latest response included it. When a new response arrives, a new instance is
|
||||
* emitted through [FavoriteAlgoFeedFlow] and replaces the active filter.
|
||||
*/
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedTopNavFilter(
|
||||
val feedAddress: Address,
|
||||
val acceptedIds: Set<HexKey>,
|
||||
val acceptedAddresses: Set<String>,
|
||||
val contentRelays: Set<NormalizedRelayUrl>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestId: HexKey?,
|
||||
) : IFeedTopNavFilter {
|
||||
override fun matchAuthor(pubkey: HexKey): Boolean = true
|
||||
|
||||
override fun match(noteEvent: Event): Boolean =
|
||||
noteEvent.id in acceptedIds ||
|
||||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
|
||||
|
||||
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
|
||||
|
||||
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
contentRelays.associateWith {
|
||||
FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
ids = acceptedIds,
|
||||
addresses = acceptedAddresses,
|
||||
)
|
||||
},
|
||||
listenRelays = listenRelays,
|
||||
requestIds = setOfNotNull(requestId),
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
val ids: Set<HexKey>,
|
||||
val addresses: Set<String>,
|
||||
) : IFeedTopNavPerRelayFilter
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Two relay sets, two distinct subscriptions:
|
||||
*
|
||||
* - [contentFetches] — for each user-configured content relay, the ids/addresses
|
||||
* we want to pull (the actual notes the DVM curated).
|
||||
* - [listenRelays] — the union of DVM publish relays across all active DVMs
|
||||
* (where they will deliver future kind 6300 / 7000 events for their requests).
|
||||
* - [requestIds] — the set of currently-active kind-5300 request ids to listen
|
||||
* for. A single feed carries one; the merged "All favorite algo feeds"
|
||||
* filter carries one per favorite algo feed.
|
||||
*/
|
||||
class FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
val contentFetches: Map<NormalizedRelayUrl, FavoriteAlgoFeedTopNavPerRelayFilter>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestIds: Set<HexKey>,
|
||||
) : IFeedTopNavPerRelayFilterSet
|
||||
@@ -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")
|
||||
+174
@@ -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<InetAddress>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
+25
@@ -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))
|
||||
|
||||
-17
@@ -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 =
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
@@ -112,6 +113,15 @@ fun RenderLoaded(
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.startsWith("application/pdf")) {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlPdf(url, uri = callbackUri, mimeType = state.previewInfo.mimeType),
|
||||
roundedCorner = true,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
|
||||
@@ -480,6 +481,9 @@ private fun RenderWordWithoutPreview(
|
||||
// Don't preview Videos
|
||||
is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||
|
||||
// Don't preview PDFs
|
||||
is PdfSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||
|
||||
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
@@ -529,6 +533,7 @@ private fun RenderWordWithPreview(
|
||||
when (word) {
|
||||
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
|
||||
|
||||
+202
@@ -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<String>,
|
||||
preloadUrls: List<String>,
|
||||
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<MediaUrlImage>) {
|
||||
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<String>,
|
||||
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<String> {
|
||||
val reasons = linkedSetOf<String>()
|
||||
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
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+81
-13
@@ -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<Boolean>,
|
||||
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<Boolean>,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages.
|
||||
private const val THUMBNAIL_MAX_DIM_PX = 1600
|
||||
|
||||
data class PdfPreview(
|
||||
val thumbnail: Bitmap,
|
||||
val pageCount: Int,
|
||||
val aspectRatio: Float,
|
||||
)
|
||||
|
||||
private sealed class PdfLoadState {
|
||||
data object Loading : PdfLoadState()
|
||||
|
||||
data class Ready(
|
||||
val preview: PdfPreview,
|
||||
) : PdfLoadState()
|
||||
|
||||
data object Failed : PdfLoadState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfPreviewCard(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) }
|
||||
|
||||
if (showPdf.value) {
|
||||
LoadedPdfPreviewCard(content, accountViewModel, onOpen)
|
||||
} else {
|
||||
PlaceholderPdfCard(content) { showPdf.value = true }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun LoadedPdfPreviewCard(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val targetWidthPx =
|
||||
remember(density, configuration) {
|
||||
val screenPx =
|
||||
with(density) {
|
||||
configuration.screenWidthDp.dp
|
||||
.toPx()
|
||||
.toInt()
|
||||
}
|
||||
screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val state by produceState<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) {
|
||||
value =
|
||||
try {
|
||||
PdfFetcher
|
||||
.fetchSnapshot(content.url) { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
|
||||
}.use { snapshot ->
|
||||
withContext(Dispatchers.IO) {
|
||||
renderFirstPage(snapshot.data.toFile(), targetWidthPx)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
|
||||
PdfLoadState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = sharePopupExpanded,
|
||||
content = content,
|
||||
onDismiss = { sharePopupExpanded.value = false },
|
||||
)
|
||||
|
||||
val filename = remember(content.url) { extractFilename(content.url) }
|
||||
|
||||
when (val current = state) {
|
||||
is PdfLoadState.Loading -> {
|
||||
PdfSkeletonCard(filename)
|
||||
}
|
||||
|
||||
is PdfLoadState.Failed -> {
|
||||
ClickableUrl(urlText = content.url, url = content.url)
|
||||
}
|
||||
|
||||
is PdfLoadState.Ready -> {
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.combinedClickable(
|
||||
onClick = onOpen,
|
||||
onLongClick = { sharePopupExpanded.value = true },
|
||||
),
|
||||
) {
|
||||
Image(
|
||||
bitmap = current.preview.thumbnail.asImageBitmap(),
|
||||
contentDescription = content.description ?: filename,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
filterQuality = FilterQuality.High,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
|
||||
)
|
||||
|
||||
FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount))
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderPdfCard(
|
||||
content: MediaUrlPdf,
|
||||
onLoad: () -> Unit,
|
||||
) {
|
||||
val filename = remember(content.url) { extractFilename(content.url) }
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onLoad, onLongClick = onLoad),
|
||||
) {
|
||||
FilenameRow(filename = filename, subtitle = "Tap to load PDF")
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfSkeletonCard(filename: String) {
|
||||
Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) {
|
||||
FilenameRow(filename = filename, subtitle = "Loading…")
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
filename: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFirstPage(
|
||||
file: java.io.File,
|
||||
targetWidthPx: Int,
|
||||
): PdfLoadState =
|
||||
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
|
||||
PdfRenderer(pfd).use { renderer ->
|
||||
val pageCount = renderer.pageCount
|
||||
if (pageCount <= 0) return@use PdfLoadState.Failed
|
||||
|
||||
renderer.openPage(0).use { page ->
|
||||
val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx)
|
||||
// PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output.
|
||||
val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
PdfLoadState.Ready(
|
||||
PdfPreview(
|
||||
thumbnail = bitmap,
|
||||
pageCount = pageCount,
|
||||
aspectRatio = page.width.toFloat() / page.height.toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bitmap dimensions to render a PDF page at, scaled so the longest side equals
|
||||
* [targetDim] while preserving aspect ratio. Always scales, never returns native size: a PDF
|
||||
* page's native width/height are in PostScript points (1/72"), which is far below any useful
|
||||
* display resolution. Since PDFs are vector, rendering at a larger target is essentially free
|
||||
* and avoids a 72-DPI-blurry bitmap.
|
||||
*/
|
||||
internal fun cappedRenderSize(
|
||||
pageWidth: Int,
|
||||
pageHeight: Int,
|
||||
targetDim: Int,
|
||||
): Pair<Int, Int> {
|
||||
if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1
|
||||
val longest = maxOf(pageWidth, pageHeight)
|
||||
val scale = targetDim.toFloat() / longest
|
||||
val w = (pageWidth * scale).toInt().coerceAtLeast(1)
|
||||
val h = (pageHeight * scale).toInt().coerceAtLeast(1)
|
||||
return w to h
|
||||
}
|
||||
|
||||
internal fun extractFilename(url: String): String {
|
||||
val afterQuery = url.substringBefore('?').substringBefore('#')
|
||||
val name = afterQuery.substringAfterLast('/', afterQuery)
|
||||
return if (name.isBlank()) url else name
|
||||
}
|
||||
|
||||
internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.toggleScale
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when
|
||||
// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes
|
||||
// of RAM (PdfRenderer requires ARGB_8888). 3072 gives ~26 MB per A4-shaped page.
|
||||
private const val VIEWER_MAX_DIM_PX = 3072
|
||||
|
||||
// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in
|
||||
// past HI_RES_ZOOM_THRESHOLD we re-render the current page at
|
||||
// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within
|
||||
// common GPU texture limits (so drawing stays hardware-accelerated) and caps
|
||||
// memory at ~48 MB for an A4-shaped page. Above 4096 most mid-range GPUs fall
|
||||
// back to software rendering, which is what caused the pan/zoom jitter.
|
||||
private const val HI_RES_MAX_DIM_PX = 4096
|
||||
private const val HI_RES_ZOOM_THRESHOLD = 1.5f
|
||||
private const val HI_RES_DEBOUNCE_MS = 200L
|
||||
|
||||
// Zoom level the viewer animates to when the user double-taps. Matches the
|
||||
// threshold region where we swap in the hi-res bitmap.
|
||||
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
|
||||
|
||||
// How many recently-rendered pages to keep around. Pager already pre-composes the
|
||||
// current page plus one neighbor; this just speeds up small back/forward swipes.
|
||||
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
|
||||
private const val PAGE_CACHE_SIZE = 3
|
||||
|
||||
private class PageBitmapCache(
|
||||
private val maxSize: Int,
|
||||
) {
|
||||
private val cache =
|
||||
object : java.util.LinkedHashMap<Int, Bitmap>(maxSize, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<Int, Bitmap>): Boolean = size > maxSize
|
||||
}
|
||||
|
||||
@Synchronized fun get(key: Int): Bitmap? = cache[key]
|
||||
|
||||
@Synchronized fun put(
|
||||
key: Int,
|
||||
value: Bitmap,
|
||||
) {
|
||||
cache[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private class PdfDocumentHandle(
|
||||
val snapshot: DiskCache.Snapshot,
|
||||
val pfd: ParcelFileDescriptor,
|
||||
val renderer: PdfRenderer,
|
||||
) {
|
||||
// Snapshot eagerly. Compose's saveable PagerState reads pageCount during
|
||||
// teardown, which can run *after* close() — so we can't query the renderer
|
||||
// lazily without tripping IllegalStateException("Document already closed").
|
||||
val pageCount: Int = renderer.pageCount
|
||||
val mutex: Mutex = Mutex()
|
||||
|
||||
@Volatile var closed: Boolean = false
|
||||
private set
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) }
|
||||
runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) }
|
||||
runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfViewerDialog(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
|
||||
PdfViewerContent(
|
||||
content = content,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfViewerContent(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
|
||||
value =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val snapshot =
|
||||
PdfFetcher.fetchSnapshot(content.url) { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
|
||||
}
|
||||
try {
|
||||
val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
val renderer = PdfRenderer(pfd)
|
||||
PdfDocumentHandle(snapshot, pfd, renderer)
|
||||
} catch (t: Throwable) {
|
||||
runCatching { snapshot.close() }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the handle as a local val so the onDispose lambda closes *this* handle,
|
||||
// not whatever the delegated property reads at dispose time. Without this, the
|
||||
// DisposableEffect keyed on handleState runs its onDispose when handleState
|
||||
// transitions from null -> handle, and `handleState?.close()` reads the new handle
|
||||
// and closes it right after it was created.
|
||||
val handleForDispose = handleState
|
||||
DisposableEffect(handleForDispose) {
|
||||
onDispose {
|
||||
handleForDispose?.close()
|
||||
}
|
||||
}
|
||||
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = sharePopupExpanded,
|
||||
content = content,
|
||||
onDismiss = { sharePopupExpanded.value = false },
|
||||
)
|
||||
|
||||
val handle = handleState
|
||||
if (handle == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else if (handle.pageCount == 0) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Unable to open PDF",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val pagerState = rememberPagerState { handle.pageCount }
|
||||
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { pageIndex ->
|
||||
PdfPageView(
|
||||
handle = handle,
|
||||
pageIndex = pageIndex,
|
||||
cache = pageCache,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||
color = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { sharePopupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun PdfPageView(
|
||||
handle: PdfDocumentHandle,
|
||||
pageIndex: Int,
|
||||
cache: PageBitmapCache,
|
||||
) {
|
||||
val cached = cache.get(pageIndex)
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val baseBitmap by produceState<Bitmap?>(initialValue = cached, key1 = handle, key2 = pageIndex) {
|
||||
if (value != null) return@produceState
|
||||
val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX)
|
||||
rendered?.let { cache.put(pageIndex, it) }
|
||||
value = rendered
|
||||
}
|
||||
|
||||
val zoomState = rememberZoomState()
|
||||
|
||||
// Re-render the page at a higher resolution once the user zooms in and settles,
|
||||
// so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base
|
||||
// bitmap. Released when zoom drops back under threshold or the page leaves view.
|
||||
var hiResBitmap by remember(handle, pageIndex) { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(handle, pageIndex, baseBitmap) {
|
||||
if (baseBitmap == null) return@LaunchedEffect
|
||||
snapshotFlow { zoomState.scale }
|
||||
.debounce(HI_RES_DEBOUNCE_MS)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { scale ->
|
||||
if (scale < HI_RES_ZOOM_THRESHOLD) {
|
||||
hiResBitmap = null
|
||||
} else {
|
||||
val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX)
|
||||
// Skip if the hi-res render wouldn't beat what we already have.
|
||||
val base = baseBitmap ?: return@collectLatest
|
||||
val baseLongest = maxOf(base.width, base.height)
|
||||
if (target <= baseLongest) {
|
||||
hiResBitmap = null
|
||||
} else {
|
||||
hiResBitmap = renderPageCatching(handle, pageIndex, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val current = hiResBitmap ?: baseBitmap
|
||||
// Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new
|
||||
// one and push Compose into thinking the texture changed.
|
||||
val imageBitmap = remember(current) { current?.asImageBitmap() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
// Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every
|
||||
// frame during pan/zoom, which is the main source of jitter when the
|
||||
// source bitmap is several megapixels. Bilinear on a 3072-4096 px source
|
||||
// looks effectively identical on-screen.
|
||||
filterQuality = FilterQuality.Medium,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
zoomState = zoomState,
|
||||
onDoubleTap = { position ->
|
||||
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
|
||||
},
|
||||
),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun renderPageCatching(
|
||||
handle: PdfDocumentHandle,
|
||||
pageIndex: Int,
|
||||
maxDim: Int,
|
||||
): Bitmap? =
|
||||
try {
|
||||
handle.mutex.withLock {
|
||||
if (handle.closed) {
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.IO) {
|
||||
handle.renderer.openPage(pageIndex).use { page ->
|
||||
val (width, height) = cappedRenderSize(page.width, page.height, maxDim)
|
||||
// PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently
|
||||
// rejected and produces blank output.
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
bmp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e)
|
||||
null
|
||||
}
|
||||
@@ -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<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
|
||||
@@ -187,6 +187,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
+64
-4
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ViewList
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Groups
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
@@ -67,6 +68,7 @@ import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
||||
import com.vitorpamplona.amethyst.ui.screen.HashtagName
|
||||
@@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@@ -150,23 +154,40 @@ fun FeedFilterSpinner(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Size20Modifier)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Bound the Column so long filter names (e.g. DVM titles) get truncated
|
||||
// instead of wrapping to multiple lines and shoving the expand icon out.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
val filter = selected?.code
|
||||
if (filter is TopFilter.Geohash) {
|
||||
LoadCityName(
|
||||
geohashStr = filter.tag,
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(filter.tag)
|
||||
Text(
|
||||
text = filter.tag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
Text(cityName)
|
||||
Text(
|
||||
text = cityName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(currentText)
|
||||
Text(
|
||||
text = currentText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (filter is TopFilter.AroundMe) {
|
||||
@@ -178,6 +199,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||
@@ -193,6 +216,8 @@ fun FeedFilterSpinner(
|
||||
text = "(${myLocation.geoHash})",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
@@ -203,6 +228,8 @@ fun FeedFilterSpinner(
|
||||
text = "($cityName)",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,6 +239,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,6 +249,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -329,6 +360,20 @@ fun RenderOption(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
is FavoriteAlgoFeedName -> {
|
||||
val noteState by observeNote(option.note, accountViewModel)
|
||||
val name =
|
||||
(noteState.note.event as? AppDefinitionEvent)
|
||||
?.appMetaData()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() } ?: option.note.dTag()
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +391,7 @@ private enum class FeedGroup(
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LOCATIONS(R.string.feed_group_locations),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
DVMS(R.string.feed_group_dvms),
|
||||
RELAYS(R.string.feed_group_relays),
|
||||
}
|
||||
|
||||
@@ -373,10 +419,15 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
|
||||
FeedGroup.LOCATIONS
|
||||
}
|
||||
|
||||
is FavoriteAlgoFeedName -> {
|
||||
FeedGroup.DVMS
|
||||
}
|
||||
|
||||
is ResourceName -> {
|
||||
when (entry.item.code) {
|
||||
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
|
||||
is TopFilter.Global -> FeedGroup.RELAYS
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
|
||||
else -> FeedGroup.FEEDS
|
||||
}
|
||||
}
|
||||
@@ -541,12 +592,21 @@ private fun FeedIcon(
|
||||
Icons.AutoMirrored.Outlined.ViewList
|
||||
}
|
||||
|
||||
is TopFilter.FavoriteAlgoFeed -> {
|
||||
Icons.Outlined.AutoAwesome
|
||||
}
|
||||
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> {
|
||||
Icons.Outlined.AutoAwesome
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (item.name) {
|
||||
is GeoHashName -> Icons.Outlined.LocationOn
|
||||
is RelayName -> Icons.Outlined.Storage
|
||||
is CommunityName -> Icons.Outlined.Groups
|
||||
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
|
||||
is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome
|
||||
else -> Icons.Outlined.Person
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BaseMediaContent>(
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
communityList: List<AddressableNote>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
favoriteAlgoFeedsList: List<AddressableNote>,
|
||||
): List<FeedDefinition> {
|
||||
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,
|
||||
|
||||
+27
-2
@@ -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<Note>) {
|
||||
launchSigner { account.removeDeletedPins(deletedNotes) }
|
||||
}
|
||||
|
||||
fun addPrivateBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
@@ -988,6 +993,20 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDeletedBookmarks(
|
||||
deletedEventIds: Set<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
) {
|
||||
launchSigner { account.removeDeletedBookmarks(deletedEventIds, deletedAddresses) }
|
||||
}
|
||||
|
||||
fun removeDeletedOldBookmarks(
|
||||
deletedEventIds: Set<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -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<String>() }
|
||||
val deletedAddresses = remember(bookmarkState) { mutableSetOf<com.vitorpamplona.quartz.nip01Core.core.Address>() }
|
||||
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 -> {
|
||||
|
||||
+71
@@ -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<String>() }
|
||||
val deletedAddresses = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf<Address>() }
|
||||
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()
|
||||
|
||||
+13
@@ -155,6 +155,19 @@ class BookmarkGroupViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeDeletedBookmarksFromGroup(
|
||||
groupIdentifier: String = bookmarkGroupIdentifier,
|
||||
deletedEventIds: Set<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
) {
|
||||
account.labeledBookmarkLists.removeDeletedBookmarksFromList(
|
||||
groupIdentifier,
|
||||
deletedEventIds,
|
||||
deletedAddresses,
|
||||
account,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class Initializer(
|
||||
val account: Account,
|
||||
|
||||
+42
-1
@@ -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<String>() }
|
||||
val deletedAddresses = remember(bookmarkState) { mutableSetOf<com.vitorpamplona.quartz.nip01Core.core.Address>() }
|
||||
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 -> {
|
||||
|
||||
+44
-54
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+83
-70
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun HomeAlgoFeedStatusBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val topFilter by accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
when (val filter = topFilter) {
|
||||
is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier)
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleAlgoFeedBanner(
|
||||
favFeed: TopFilter.FavoriteAlgoFeed,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator
|
||||
.observe(favFeed.address)
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Hide the banner when the feed is already populated.
|
||||
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
|
||||
|
||||
val feedAddressValue = favFeed.address.toValue()
|
||||
|
||||
LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote ->
|
||||
val resolvedName by
|
||||
observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)
|
||||
?.appMetaData()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag()
|
||||
?: ""
|
||||
}
|
||||
|
||||
BannerCard(modifier) {
|
||||
val status = snapshot.latestStatus?.status()
|
||||
|
||||
when {
|
||||
snapshot.errorMessage != null -> {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_error),
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
|
||||
}
|
||||
|
||||
status?.code == "payment-required" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_payment_required)
|
||||
},
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
var statusOverride by remember { mutableStateOf<String?>(null) }
|
||||
val msg = statusOverride
|
||||
if (msg != null) {
|
||||
Text(text = msg, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
snapshot.latestStatus?.let {
|
||||
DvmPaymentActions(
|
||||
latestStatus = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onStatusUpdate = { statusOverride = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
status?.code == "error" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_error)
|
||||
},
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
|
||||
}
|
||||
|
||||
status?.code == "processing" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_processing)
|
||||
},
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_requesting, resolvedName),
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllFavoriteAlgoFeedsBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (addresses.isEmpty()) return
|
||||
|
||||
// Observe each DVM's snapshot so we can decide whether to hide the banner
|
||||
// based on the aggregate state. Hide it as soon as any DVM has produced a
|
||||
// feed; only error out when every one of them has errored.
|
||||
val snapshots: List<FavoriteAlgoFeedsSnapshot> =
|
||||
addresses.map { address ->
|
||||
val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator
|
||||
.observe(address)
|
||||
.collectAsStateWithLifecycle()
|
||||
snap
|
||||
}
|
||||
|
||||
val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() }
|
||||
if (anyResponded) return
|
||||
|
||||
val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" }
|
||||
|
||||
BannerCard(modifier) {
|
||||
if (allErrored) {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_error),
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton {
|
||||
addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
|
||||
}
|
||||
} else {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_requesting_all),
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Sits above the feed instead of in the topBar Column, so adding/removing
|
||||
// the banner doesn't shift the tabs/filter up and down. tonalElevation
|
||||
// gives it a faint surface tint so it reads as a floating overlay.
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerMessageRow(
|
||||
message: String,
|
||||
showSpinner: Boolean,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
if (showSpinner) {
|
||||
LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RetryButton(onClick: () -> Unit) {
|
||||
OutlinedButton(onClick = onClick) {
|
||||
Text(stringRes(R.string.dvm_home_retry))
|
||||
}
|
||||
}
|
||||
+67
-18
@@ -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(
|
||||
|
||||
+3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
|
||||
/**
|
||||
* Builds relay REQ filters for a favorite-DVM home feed.
|
||||
*
|
||||
* Two distinct subscription kinds with two distinct relay sets:
|
||||
*
|
||||
* - **Content fetch** — for each of the user's outbox/proxy relays, request the
|
||||
* note IDs and addressable references the DVM curated. Notes typically live on
|
||||
* the user's normal relays, so this is where we fetch them.
|
||||
*
|
||||
* - **Response listen** — for each relay the DVM advertised (where it received
|
||||
* the kind-5300 request and will publish its 6300/7000 reply), subscribe to
|
||||
* future kind 6300 / 7000 events tagged with the request id. The DVM almost
|
||||
* never publishes responses on the user's outbox, so listening anywhere else
|
||||
* would silently miss them.
|
||||
*/
|
||||
fun filterHomePostsByAlgoFeedIds(
|
||||
set: FavoriteAlgoFeedTopNavPerRelayFilterSet,
|
||||
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
|
||||
@Suppress("UNUSED_PARAMETER") defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
val out = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
set.contentFetches.forEach { (relay, filter) ->
|
||||
out += contentFetchFilters(relay, filter)
|
||||
}
|
||||
|
||||
if (set.requestIds.isNotEmpty()) {
|
||||
val requestIds = set.requestIds.toList()
|
||||
set.listenRelays.forEach { relay ->
|
||||
out += responseListenFilter(relay, requestIds)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun contentFetchFilters(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: FavoriteAlgoFeedTopNavPerRelayFilter,
|
||||
): List<RelayBasedFilter> {
|
||||
val out = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
if (filter.ids.isNotEmpty()) {
|
||||
out +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
ids = filter.ids.toList(),
|
||||
limit = filter.ids.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (filter.addresses.isNotEmpty()) {
|
||||
out +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
tags = mapOf("a" to filter.addresses.toList()),
|
||||
limit = filter.addresses.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun responseListenFilter(
|
||||
relay: NormalizedRelayUrl,
|
||||
requestIds: List<HexKey>,
|
||||
) = RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds =
|
||||
listOf(
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to requestIds),
|
||||
limit = 10 * requestIds.size,
|
||||
),
|
||||
)
|
||||
+44
-17
@@ -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<Color>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -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<Note>?,
|
||||
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,
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+19
-3
@@ -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<BaseMediaContent>(
|
||||
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,
|
||||
|
||||
+22
-5
@@ -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,
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní
|
||||
a proto zprávy časem mizí</string>
|
||||
<string name="public_chat">Veřejný chat</string>
|
||||
<string name="marmot_group">MLS skupina</string>
|
||||
<string name="public_chat_title">Metadata veřejného chatu</string>
|
||||
<string name="public_chat_explainer">Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli
|
||||
se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat.
|
||||
@@ -389,6 +390,7 @@
|
||||
<string name="pictures">Obrázky</string>
|
||||
<string name="shorts">Krátká videa</string>
|
||||
<string name="longs">Videa</string>
|
||||
<string name="articles">Články</string>
|
||||
<string name="private_bookmarks">Soukromé záložky</string>
|
||||
<string name="public_bookmarks">Veřejné záložky</string>
|
||||
<string name="add_to_private_bookmarks">Přidat do soukromých záložek</string>
|
||||
@@ -396,8 +398,12 @@
|
||||
<string name="remove_from_private_bookmarks">Odebrat ze soukromých záložek</string>
|
||||
<string name="remove_from_public_bookmarks">Odebrat z veřejných záložek</string>
|
||||
<string name="pinned_notes">Připnuté poznámky</string>
|
||||
<string name="pinned_notes_explainer">Vaše připnuté poznámky</string>
|
||||
<string name="pin_to_profile">Připnout na profil</string>
|
||||
<string name="unpin_from_profile">Odepnout z profilu</string>
|
||||
<string name="deleted_items_banner_title">%1$d položek v tomto seznamu bylo smazáno autorem.</string>
|
||||
<string name="deleted_items_banner_remove">Odebrat ze seznamu</string>
|
||||
<string name="deleted_items_banner_dismiss">Zavřít</string>
|
||||
<string name="bookmark_lists">Seznamy záložek</string>
|
||||
<string name="bookmark_list_icon_label">Ikona seznamu záložek</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nový seznam záložek</string>
|
||||
@@ -712,6 +718,8 @@
|
||||
<string name="call_failed_accept">Nepodařilo se přijmout hovor</string>
|
||||
<string name="call_failed_session">Nepodařilo se vytvořit relaci hovoru</string>
|
||||
<string name="call_settings">Nastavení hovorů</string>
|
||||
<string name="call_settings_enable_calls">Povolit hlasové a video hovory</string>
|
||||
<string name="call_settings_enable_calls_description">Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány.</string>
|
||||
<string name="call_settings_video_quality">Kvalita videa</string>
|
||||
<string name="call_settings_max_bitrate">Maximální datový tok videa</string>
|
||||
<string name="call_settings_turn_servers">TURN / STUN servery</string>
|
||||
@@ -937,6 +945,7 @@
|
||||
<string name="geohash_exclusive_explainer">Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exkluzivní příspěvek</string>
|
||||
<string name="hashtag_exclusive_explainer">Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí.</string>
|
||||
<string name="long_form_reading_minutes">%1$d min čtení</string>
|
||||
<string name="loading_location">Načítání umístění</string>
|
||||
<string name="lack_location_permissions">Žádná lokace oprávnění</string>
|
||||
<string name="add_sensitive_content_explainer">Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující</string>
|
||||
@@ -945,6 +954,7 @@
|
||||
<string name="new_feature_nip17_might_not_be_available_description">Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-17 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-17 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta.</string>
|
||||
<string name="new_feature_nip17_activate">Aktivovat</string>
|
||||
<string name="messages_create_public_chat">Veřejné</string>
|
||||
<string name="messages_create_group">Skupina</string>
|
||||
<string name="messages_create_public_private_chat_description">Nová veřejná nebo soukromá skupina</string>
|
||||
<string name="messages_relay_based">Relé</string>
|
||||
<string name="messages_new_message">Soukromé</string>
|
||||
@@ -1338,6 +1348,8 @@
|
||||
<string name="private_inbox_section">DM schránka relé</string>
|
||||
<string name="private_inbox_section_explainer_profile">Uživatel přijímá soukromé zprávy na těchto přenašečích</string>
|
||||
<string name="private_inbox_section_explainer">Vložte mezi 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é)</string>
|
||||
<string name="keypackage_section">KeyPackage relays</string>
|
||||
<string name="keypackage_section_explainer">Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení.</string>
|
||||
<string name="private_outbox_section">Soukromá relé</string>
|
||||
<string name="private_outbox_section_explainer">Vložte mezi 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.</string>
|
||||
<string name="kind_3_section">Obecná relé</string>
|
||||
@@ -1475,7 +1487,20 @@
|
||||
<string name="feed_group_locations">Lokace</string>
|
||||
<string name="feed_group_communities">Komunity</string>
|
||||
<string name="feed_group_lists">Seznamy</string>
|
||||
<string name="feed_group_dvms">Algoritmy zdrojů</string>
|
||||
<string name="follow_list_all_favorite_dvms">Všechny oblíbené algoritmy zdrojů</string>
|
||||
<string name="feed_group_relays">Relé</string>
|
||||
<string name="add_dvm_to_favorites">Přidat algoritmus zdroje k oblíbeným</string>
|
||||
<string name="remove_dvm_from_favorites">Odebrat z oblíbených</string>
|
||||
<string name="favorite_dvms_title">Oblíbené algoritmy zdrojů</string>
|
||||
<string name="favorite_dvms_explainer">Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších.</string>
|
||||
<string name="favorite_dvms_empty">Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou.</string>
|
||||
<string name="dvm_home_status_requesting">Žádost %1$s o zdroj…</string>
|
||||
<string name="dvm_home_status_requesting_all">Žádost oblíbených algoritmů o zdroje…</string>
|
||||
<string name="dvm_home_status_processing">Zpracování zdroje…</string>
|
||||
<string name="dvm_home_status_payment_required">Tento algoritmus zdroje vyžaduje platbu</string>
|
||||
<string name="dvm_home_status_error">Algoritmus zdroje vrátil chybu</string>
|
||||
<string name="dvm_home_retry">Zkusit znovu</string>
|
||||
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
|
||||
<string name="private_message">Soukromá zpráva</string>
|
||||
<string name="public_message">Veřejná zpráva</string>
|
||||
@@ -1819,6 +1844,41 @@
|
||||
<string name="playback_actions_dialog_title">Přehrávání</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">HLS nahrávání</string>
|
||||
<string name="share_hls_video_drawer_description">Publikujte multi-rozlišení HLS na váš media server</string>
|
||||
<string name="hls_pick_video_primary">Vyberte video</string>
|
||||
<string name="hls_pick_video_helper">Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení.</string>
|
||||
<string name="hls_change_video">Změnit</string>
|
||||
<string name="hls_title_label">Titulek</string>
|
||||
<string name="hls_title_placeholder">Zadejte titulek videa</string>
|
||||
<string name="hls_description_label">Popis</string>
|
||||
<string name="hls_description_placeholder">O čem je toto video?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Důvod (nepovinné)</string>
|
||||
<string name="hls_codec_label">Kodek</string>
|
||||
<string name="hls_codec_h265">H.265 (lepší komprese)</string>
|
||||
<string name="hls_codec_fallback_notice">H.265 není na tomto zařízení dostupný — používá se H.264.</string>
|
||||
<string name="hls_renditions_label">Rozlišení</string>
|
||||
<string name="hls_renditions_source_format">Zdrojové rozlišení: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Bude vytvořeno: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s přeskočeno — nad zdrojem)</string>
|
||||
<string name="hls_rendition_above_source">nad zdrojem — bude přeskočeno</string>
|
||||
<string name="hls_publish_button">Publikovat HD video</string>
|
||||
<string name="hls_publishing_header_format">Publikování „%1$s“…</string>
|
||||
<string name="hls_state_transcoding_format">Překódování %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Nahrát</string>
|
||||
<string name="hls_state_uploading_format">Nahrávání %1$d z %2$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">Nahrávání %1$s (%2$d z %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">Nahráno %1$d z %2$d</string>
|
||||
<string name="hls_state_publishing">Publikování události…</string>
|
||||
<string name="hls_state_success_title">Video publikováno</string>
|
||||
<string name="hls_state_success_body">Vaše HD video je publikováno na Nostr.</string>
|
||||
<string name="hls_state_failure_title">Něco se pokazilo</string>
|
||||
<string name="hls_view_note">Zobrazit poznámku</string>
|
||||
<string name="hls_done">Hotovo</string>
|
||||
<string name="hls_try_again">Zkusit znovu</string>
|
||||
<string name="hls_draft_note_after_upload">Vytvořit poznámku po nahrání</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním.</string>
|
||||
<string name="hls_draft_note_button">Vytvořit poznámku</string>
|
||||
<string name="pack_actions_dialog_title">Akce balíčku</string>
|
||||
<string name="list_actions_dialog_title">Akce seznamu</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
|
||||
|
||||
@@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig
|
||||
und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string>
|
||||
<string name="public_chat">Öffentlicher Chat</string>
|
||||
<string name="marmot_group">MLS-Gruppe</string>
|
||||
<string name="public_chat_title">Öffentliche Chat Metadaten</string>
|
||||
<string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder
|
||||
kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen.
|
||||
@@ -395,6 +396,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="pictures">Bilder</string>
|
||||
<string name="shorts">Kurzvideos</string>
|
||||
<string name="longs">Videos</string>
|
||||
<string name="articles">Artikel</string>
|
||||
<string name="private_bookmarks">Private Lesezeichen</string>
|
||||
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
|
||||
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
|
||||
@@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
|
||||
<string name="remove_from_public_bookmarks">Aus den öffentlichen Lesezeichen entfernen</string>
|
||||
<string name="pinned_notes">Angeheftete Notizen</string>
|
||||
<string name="pinned_notes_explainer">Deine angepinnten Notizen</string>
|
||||
<string name="pin_to_profile">An Profil anheften</string>
|
||||
<string name="unpin_from_profile">Von Profil lösen</string>
|
||||
<string name="deleted_items_banner_title">%1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht.</string>
|
||||
<string name="deleted_items_banner_remove">Aus Liste entfernen</string>
|
||||
<string name="deleted_items_banner_dismiss">Verwerfen</string>
|
||||
<string name="bookmark_lists">Lesezeichenlisten</string>
|
||||
<string name="bookmark_list_icon_label">Symbol für Lesezeichenliste</string>
|
||||
<string name="bookmark_list_creation_screen_title">Neue Lesezeichenliste</string>
|
||||
@@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="call_failed_accept">Anruf konnte nicht angenommen werden</string>
|
||||
<string name="call_failed_session">Anrufsitzung konnte nicht erstellt werden</string>
|
||||
<string name="call_settings">Anrufeinstellungen</string>
|
||||
<string name="call_settings_enable_calls">Sprach- und Videoanrufe aktivieren</string>
|
||||
<string name="call_settings_enable_calls_description">Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert.</string>
|
||||
<string name="call_settings_video_quality">Videoqualität</string>
|
||||
<string name="call_settings_max_bitrate">Maximale Video-Bitrate</string>
|
||||
<string name="call_settings_turn_servers">TURN- / STUN-Server</string>
|
||||
@@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="geohash_exclusive_explainer">Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string>
|
||||
<string name="hashtag_exclusive_explainer">Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen.</string>
|
||||
<string name="long_form_reading_minutes">%1$d Min. Lesezeit</string>
|
||||
<string name="loading_location">Standort wird geladen</string>
|
||||
<string name="lack_location_permissions">Keine Standortberechtigungen</string>
|
||||
<string name="add_sensitive_content_explainer">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten</string>
|
||||
@@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_description">Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet.</string>
|
||||
<string name="new_feature_nip17_activate">Aktivieren</string>
|
||||
<string name="messages_create_public_chat">Öffentlich</string>
|
||||
<string name="messages_create_group">Gruppe</string>
|
||||
<string name="messages_create_public_private_chat_description">Neue öffentliche oder private Gruppe</string>
|
||||
<string name="messages_relay_based">Relais</string>
|
||||
<string name="messages_new_message">Privat</string>
|
||||
@@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="private_inbox_section">DM-Posteingangsrelais</string>
|
||||
<string name="private_inbox_section_explainer_profile">Der Benutzer empfängt Direktnachrichten auf diesen Relays</string>
|
||||
<string name="private_inbox_section_explainer">Fügen Sie 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)</string>
|
||||
<string name="keypackage_section">KeyPackage-Relays</string>
|
||||
<string name="keypackage_section_explainer">Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben.</string>
|
||||
<string name="private_outbox_section">Private Relais</string>
|
||||
<string name="private_outbox_section_explainer">Fügen Sie zwischen 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.</string>
|
||||
<string name="kind_3_section">Allgemeine Relais</string>
|
||||
@@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="feed_group_locations">Standorte</string>
|
||||
<string name="feed_group_communities">Gemeinschaften</string>
|
||||
<string name="feed_group_lists">Listen</string>
|
||||
<string name="feed_group_dvms">Feed-Algorithmen</string>
|
||||
<string name="follow_list_all_favorite_dvms">Alle bevorzugten Feed-Algorithmen</string>
|
||||
<string name="feed_group_relays">Relais</string>
|
||||
<string name="add_dvm_to_favorites">Feed-Algorithmus zu Favoriten hinzufügen</string>
|
||||
<string name="remove_dvm_from_favorites">Aus Favoriten entfernen</string>
|
||||
<string name="favorite_dvms_title">Bevorzugte Feed-Algorithmen</string>
|
||||
<string name="favorite_dvms_explainer">Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen.</string>
|
||||
<string name="favorite_dvms_empty">Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen.</string>
|
||||
<string name="dvm_home_status_requesting">Frage %1$s nach einem Feed…</string>
|
||||
<string name="dvm_home_status_requesting_all">Frage deine bevorzugten Feed-Algorithmen nach Feeds…</string>
|
||||
<string name="dvm_home_status_processing">Feed wird verarbeitet…</string>
|
||||
<string name="dvm_home_status_payment_required">Dieser Feed-Algorithmus erfordert eine Zahlung</string>
|
||||
<string name="dvm_home_status_error">Der Feed-Algorithmus hat einen Fehler zurückgegeben</string>
|
||||
<string name="dvm_home_retry">Wiederholen</string>
|
||||
<string name="temporary_account">Beim Sperren des Geräts abmelden</string>
|
||||
<string name="private_message">Private Nachricht</string>
|
||||
<string name="public_message">Öffentliche Nachricht</string>
|
||||
@@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="playback_actions_dialog_title">Wiedergabe</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">HLS-Upload</string>
|
||||
<string name="share_hls_video_drawer_description">Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver</string>
|
||||
<string name="hls_pick_video_primary">Video auswählen</string>
|
||||
<string name="hls_pick_video_helper">Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten.</string>
|
||||
<string name="hls_change_video">Ändern</string>
|
||||
<string name="hls_title_label">Titel</string>
|
||||
<string name="hls_title_placeholder">Gib deinem Video einen Titel</string>
|
||||
<string name="hls_description_label">Beschreibung</string>
|
||||
<string name="hls_description_placeholder">Worum geht es in diesem Video?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Grund (optional)</string>
|
||||
<string name="hls_codec_h265">H.265 (bessere Komprimierung)</string>
|
||||
<string name="hls_codec_fallback_notice">H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264.</string>
|
||||
<string name="hls_renditions_label">Versionen</string>
|
||||
<string name="hls_renditions_source_format">Quellauflösung: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Wird erstellt: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s übersprungen — über Quelle)</string>
|
||||
<string name="hls_rendition_above_source">über Quelle — wird übersprungen</string>
|
||||
<string name="hls_publish_button">HD-Video veröffentlichen</string>
|
||||
<string name="hls_publishing_header_format">Veröffentliche „%1$s“…</string>
|
||||
<string name="hls_state_transcoding_format">Transkodiere %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Hochladen</string>
|
||||
<string name="hls_state_uploading_format">Lade %1$d von %2$d hoch</string>
|
||||
<string name="hls_state_uploading_with_label_format">Lade %1$s hoch (%2$d von %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">%1$d von %2$d hochgeladen</string>
|
||||
<string name="hls_state_publishing">Veröffentliche Ereignis…</string>
|
||||
<string name="hls_state_success_title">Video veröffentlicht</string>
|
||||
<string name="hls_state_success_body">Dein HD-Video ist auf Nostr live.</string>
|
||||
<string name="hls_state_failure_title">Etwas ist schiefgelaufen</string>
|
||||
<string name="hls_view_note">Notiz anzeigen</string>
|
||||
<string name="hls_done">Fertig</string>
|
||||
<string name="hls_try_again">Erneut versuchen</string>
|
||||
<string name="hls_draft_note_after_upload">Notiz nach Upload entwerfen</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst.</string>
|
||||
<string name="hls_draft_note_button">Notiz entwerfen</string>
|
||||
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
|
||||
<string name="list_actions_dialog_title">Listenaktionen</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
|
||||
|
||||
@@ -1350,6 +1350,7 @@
|
||||
<string name="private_inbox_section">सीधा संदेश आगतपेटिका पुनःप्रसारक</string>
|
||||
<string name="private_inbox_section_explainer_profile">प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है</string>
|
||||
<string name="private_inbox_section_explainer">आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क)</string>
|
||||
<string name="keypackages">कुंचिकापोटलियाँ</string>
|
||||
<string name="keypackage_section">कुंचिकापोटली पुनःप्रसारक</string>
|
||||
<string name="keypackage_section_explainer">पुनःप्रसारक जहाँ आपके एमएलएस॰ कुंचिकापोटलियाँ प्रकाशित होंगे (मिप॰००)। अन्य प्रयोक्ता इन कुंचिकापोटलियों को प्राप्त करेंगे आपको आमन्त्रित करने के लिए मार्मोट॰ समूह चर्चाओं में। १ - ३ पुनःप्रसारकों को जोडें जो आप से कुंचिकापोटली घटनाएँ स्वीकारते हों तथा सार्वजनिक पठन की अनुमती देते हों।</string>
|
||||
<string name="private_outbox_section">निजी पुनःप्रसारक</string>
|
||||
@@ -1851,6 +1852,25 @@
|
||||
<string name="hls_renditions_source_format">स्रोत सुलझाव : %1$dघ्न%2$d</string>
|
||||
<string name="hls_renditions_produce_format">उत्पाद्य : %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s त्यक्त - स्रोत ऊपर)</string>
|
||||
<string name="hls_rendition_bitrate_kbps_format">%1$d सहस्रांकप्रतिसेकण्ड</string>
|
||||
<string name="hls_rendition_above_source">स्रोत ऊपर - त्यक्तव्य</string>
|
||||
<string name="hls_publish_button">उच्छनिरूपण दृश्याभिलेख प्रकाशित करें</string>
|
||||
<string name="hls_publishing_header_format">प्रकाशन चालू \"%1$s\"…</string>
|
||||
<string name="hls_state_transcoding_format">संकुचनान्तरण चालू %1$s</string>
|
||||
<string name="hls_state_uploading_idle">आरोहण</string>
|
||||
<string name="hls_state_uploading_format">आरोहण चालू %2$d में से %1$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">आरोहण चालू %1$s (%3$d में से %2$d)</string>
|
||||
<string name="hls_state_uploaded_format">आरोहण कृत %2$d में से %1$d</string>
|
||||
<string name="hls_state_publishing">घटना प्रकाशन चालू…</string>
|
||||
<string name="hls_state_success_title">दृश्याभिलेख प्रकाशित</string>
|
||||
<string name="hls_state_success_body">आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है।</string>
|
||||
<string name="hls_state_failure_title">कुछ गडबड हुआ</string>
|
||||
<string name="hls_view_note">टीका देखें</string>
|
||||
<string name="hls_done">हो गया</string>
|
||||
<string name="hls_try_again">पुनः प्रयास करें</string>
|
||||
<string name="hls_draft_note_after_upload">टीका सम्पादन आरोहण पश्चात</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व।</string>
|
||||
<string name="hls_draft_note_button">टीका सम्पादन</string>
|
||||
<string name="pack_actions_dialog_title">पोटली कार्य</string>
|
||||
<string name="list_actions_dialog_title">सूची कार्य</string>
|
||||
<string name="bookmark_item_actions_dialog_title">स्मर्त्तव्यचिह्न कार्य</string>
|
||||
|
||||
@@ -1350,6 +1350,9 @@
|
||||
<string name="private_inbox_section">Bejövő közvetlen üzenet-átjátszók</string>
|
||||
<string name="private_inbox_section_explainer_profile">A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket</string>
|
||||
<string name="private_inbox_section_explainer">Adjon hozzá 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)</string>
|
||||
<string name="keypackages">Kulcscsomagok</string>
|
||||
<string name="keypackage_section">Kulcscsomag-átjátszók</string>
|
||||
<string name="keypackage_section_explainer">Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1-3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását.</string>
|
||||
<string name="private_outbox_section">Privát saját átjátszók</string>
|
||||
<string name="private_outbox_section_explainer">Adjon hozzá 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.</string>
|
||||
<string name="kind_3_section">Általános átjátszók</string>
|
||||
|
||||
@@ -1346,6 +1346,7 @@
|
||||
<string name="private_inbox_section">Odbiorcze transmitery DM</string>
|
||||
<string name="private_inbox_section_explainer_profile">Użytkownik otrzymuje DM na tych transmiterach</string>
|
||||
<string name="private_inbox_section_explainer">Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny)</string>
|
||||
<string name="keypackages">Pakiety kluczy</string>
|
||||
<string name="keypackage_section">Transmitery pakietu kluczy</string>
|
||||
<string name="keypackage_section_explainer">Transmitery, na których publikowane są Twoje pakiety kluczy MLS (MIP-00). Inni użytkownicy pobierają te pakiety kluczy, aby zaprosić Cię do czatów grupowych w aplikacji Marmot. Wprowadź od 1 do 3 transmiterów, które akceptują zdarzenia związane z pakietami kluczy od Ciebie i zezwalają na publiczny dostęp do tych danych.</string>
|
||||
<string name="private_outbox_section">Transmitery Prywatne</string>
|
||||
|
||||
@@ -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</string>
|
||||
<string name="public_chat">Chat público</string>
|
||||
<string name="marmot_group">Grupo MLS</string>
|
||||
<string name="public_chat_title">Metadados do Chat Público</string>
|
||||
<string name="public_chat_explainer">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 @@
|
||||
<string name="pictures">Imagens</string>
|
||||
<string name="shorts">Curtas</string>
|
||||
<string name="longs">Vídeos</string>
|
||||
<string name="articles">Artigos</string>
|
||||
<string name="private_bookmarks">Itens Salvos Privados</string>
|
||||
<string name="public_bookmarks">Itens Salvos Públicos</string>
|
||||
<string name="add_to_private_bookmarks">Adicionar aos Itens Salvos Privados</string>
|
||||
@@ -396,8 +398,12 @@
|
||||
<string name="remove_from_private_bookmarks">Remover dos Itens Salvos Privados</string>
|
||||
<string name="remove_from_public_bookmarks">Remover dos Itens Salvos Públicos</string>
|
||||
<string name="pinned_notes">Notas Fixadas</string>
|
||||
<string name="pinned_notes_explainer">Suas notas fixadas</string>
|
||||
<string name="pin_to_profile">Fixar no Perfil</string>
|
||||
<string name="unpin_from_profile">Desafixar do Perfil</string>
|
||||
<string name="deleted_items_banner_title">%1$d item(ns) desta lista foram excluídos pelos seus autores.</string>
|
||||
<string name="deleted_items_banner_remove">Remover da lista</string>
|
||||
<string name="deleted_items_banner_dismiss">Dispensar</string>
|
||||
<string name="bookmark_lists">Listas de favoritos</string>
|
||||
<string name="bookmark_list_icon_label">Ícone da lista de favoritos</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nova lista de favoritos</string>
|
||||
@@ -712,6 +718,8 @@
|
||||
<string name="call_failed_accept">Falha ao aceitar chamada</string>
|
||||
<string name="call_failed_session">Falha ao criar sessão de chamada</string>
|
||||
<string name="call_settings">Configurações de Chamada</string>
|
||||
<string name="call_settings_enable_calls">Ativar chamadas de voz e vídeo</string>
|
||||
<string name="call_settings_enable_calls_description">Quando desativado, os botões de chamada ficam ocultos nas telas de conversa e todas as chamadas recebidas são silenciosamente ignoradas.</string>
|
||||
<string name="call_settings_video_quality">Qualidade do Vídeo</string>
|
||||
<string name="call_settings_max_bitrate">Taxa de Bits Máxima de Vídeo</string>
|
||||
<string name="call_settings_turn_servers">Servidores TURN / STUN</string>
|
||||
@@ -937,6 +945,7 @@
|
||||
<string name="geohash_exclusive_explainer">Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso.</string>
|
||||
<string name="hashtag_exclusive">Postagem exclusiva de Hashtag</string>
|
||||
<string name="hashtag_exclusive_explainer">Somente seguidores da hashtag verão isso. Seus seguidores gerais não verão isso.</string>
|
||||
<string name="long_form_reading_minutes">%1$d min de leitura</string>
|
||||
<string name="loading_location">Carregando localização</string>
|
||||
<string name="lack_location_permissions">Sem permissões para localização</string>
|
||||
<string name="add_sensitive_content_explainer">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</string>
|
||||
@@ -945,6 +954,7 @@
|
||||
<string name="new_feature_nip17_might_not_be_available_description">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.</string>
|
||||
<string name="new_feature_nip17_activate">Ativar</string>
|
||||
<string name="messages_create_public_chat">Público</string>
|
||||
<string name="messages_create_group">Grupo</string>
|
||||
<string name="messages_create_public_private_chat_description">Novo Grupo Público ou Privado</string>
|
||||
<string name="messages_relay_based">Relé</string>
|
||||
<string name="messages_new_message">Privado</string>
|
||||
@@ -1338,6 +1348,8 @@
|
||||
<string name="private_inbox_section">Relés de Caixa de Entrada de DM</string>
|
||||
<string name="private_inbox_section_explainer_profile">O usuário recebe mensagens diretas (DMs) nesses relays</string>
|
||||
<string name="private_inbox_section_explainer">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)</string>
|
||||
<string name="keypackage_section">Relays de KeyPackage</string>
|
||||
<string name="keypackage_section_explainer">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.</string>
|
||||
<string name="private_outbox_section">Relés privados</string>
|
||||
<string name="private_outbox_section_explainer">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.</string>
|
||||
<string name="kind_3_section">Relés Gerais</string>
|
||||
@@ -1475,7 +1487,20 @@
|
||||
<string name="feed_group_locations">Localizações</string>
|
||||
<string name="feed_group_communities">Comunidades</string>
|
||||
<string name="feed_group_lists">Listas</string>
|
||||
<string name="feed_group_dvms">Algoritmos de feed</string>
|
||||
<string name="follow_list_all_favorite_dvms">Todos os algoritmos de feed favoritos</string>
|
||||
<string name="feed_group_relays">Relés</string>
|
||||
<string name="add_dvm_to_favorites">Adicionar algoritmo de feed aos favoritos</string>
|
||||
<string name="remove_dvm_from_favorites">Remover dos favoritos</string>
|
||||
<string name="favorite_dvms_title">Algoritmos de feed favoritos</string>
|
||||
<string name="favorite_dvms_explainer">Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais.</string>
|
||||
<string name="favorite_dvms_empty">Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui.</string>
|
||||
<string name="dvm_home_status_requesting">Solicitando feed a %1$s…</string>
|
||||
<string name="dvm_home_status_requesting_all">Solicitando feeds aos seus algoritmos favoritos…</string>
|
||||
<string name="dvm_home_status_processing">Processando seu feed…</string>
|
||||
<string name="dvm_home_status_payment_required">Este algoritmo de feed requer pagamento</string>
|
||||
<string name="dvm_home_status_error">O algoritmo de feed retornou um erro</string>
|
||||
<string name="dvm_home_retry">Tentar novamente</string>
|
||||
<string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string>
|
||||
<string name="private_message">Mensagem Privada</string>
|
||||
<string name="public_message">Mensagem pública</string>
|
||||
@@ -1819,6 +1844,40 @@
|
||||
<string name="playback_actions_dialog_title">Reprodução</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">Upload HLS</string>
|
||||
<string name="share_hls_video_drawer_description">Publique HLS multi-resolução em seu servidor de mídia</string>
|
||||
<string name="hls_pick_video_primary">Escolher um vídeo</string>
|
||||
<string name="hls_pick_video_helper">Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão.</string>
|
||||
<string name="hls_change_video">Trocar</string>
|
||||
<string name="hls_title_label">Título</string>
|
||||
<string name="hls_title_placeholder">Dê um título ao seu vídeo</string>
|
||||
<string name="hls_description_label">Descrição</string>
|
||||
<string name="hls_description_placeholder">Sobre o que é este vídeo?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Motivo (opcional)</string>
|
||||
<string name="hls_codec_h265">H.265 (melhor compressão)</string>
|
||||
<string name="hls_codec_fallback_notice">H.265 não disponível neste dispositivo — usando H.264.</string>
|
||||
<string name="hls_renditions_label">Versões</string>
|
||||
<string name="hls_renditions_source_format">Resolução da origem: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Será gerado: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s ignorado — acima da origem)</string>
|
||||
<string name="hls_rendition_above_source">acima da origem — será ignorado</string>
|
||||
<string name="hls_publish_button">Publicar vídeo HD</string>
|
||||
<string name="hls_publishing_header_format">Publicando “%1$s”…</string>
|
||||
<string name="hls_state_transcoding_format">Transcodificando %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Enviar</string>
|
||||
<string name="hls_state_uploading_format">Enviando %1$d de %2$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">Enviando %1$s (%2$d de %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">Enviado %1$d de %2$d</string>
|
||||
<string name="hls_state_publishing">Publicando evento…</string>
|
||||
<string name="hls_state_success_title">Vídeo publicado</string>
|
||||
<string name="hls_state_success_body">Seu vídeo HD está publicado no Nostr.</string>
|
||||
<string name="hls_state_failure_title">Algo deu errado</string>
|
||||
<string name="hls_view_note">Ver nota</string>
|
||||
<string name="hls_done">Concluído</string>
|
||||
<string name="hls_try_again">Tentar novamente</string>
|
||||
<string name="hls_draft_note_after_upload">Rascunho de nota após upload</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">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.</string>
|
||||
<string name="hls_draft_note_button">Rascunhar nota</string>
|
||||
<string name="pack_actions_dialog_title">Ações do pacote</string>
|
||||
<string name="list_actions_dialog_title">Ações da lista</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Ações de favorito</string>
|
||||
|
||||
@@ -281,6 +281,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
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</string>
|
||||
<string name="public_chat">Javni pogovor</string>
|
||||
<string name="marmot_group">MLS skupina</string>
|
||||
<string name="public_chat_title">Metapodatki Javnega Klepeta</string>
|
||||
<string name="public_chat_explainer">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</string>
|
||||
<string name="pictures">Fotografije</string>
|
||||
<string name="shorts">Kratki posnetki</string>
|
||||
<string name="longs">Videoposnetki</string>
|
||||
<string name="articles">Članki</string>
|
||||
<string name="private_bookmarks">Privatni zaznamki</string>
|
||||
<string name="public_bookmarks">Javni zaznamki</string>
|
||||
<string name="add_to_private_bookmarks">Dodaj v privatne zaznamke</string>
|
||||
@@ -409,8 +411,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="remove_from_private_bookmarks">Odstrani iz privatnih zaznamkov</string>
|
||||
<string name="remove_from_public_bookmarks">Odstrani iz javnih zaznamkov</string>
|
||||
<string name="pinned_notes">Pripeti zapiski</string>
|
||||
<string name="pinned_notes_explainer">Vaši pripeti zapiski</string>
|
||||
<string name="pin_to_profile">Pripni k profilu</string>
|
||||
<string name="unpin_from_profile">Odpni od profila</string>
|
||||
<string name="deleted_items_banner_remove">Odstrani iz seznama</string>
|
||||
<string name="deleted_items_banner_dismiss">Prekliči</string>
|
||||
<string name="bookmark_lists">Seznam zaznamkov</string>
|
||||
<string name="bookmark_list_icon_label">Ikona za seznam zaznamkov</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nov seznam zaznamkov</string>
|
||||
@@ -727,6 +732,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="call_failed_accept">Sprejem klica ni uspel</string>
|
||||
<string name="call_failed_session">Vzpostavitev klicne seje ni uspela</string>
|
||||
<string name="call_settings">Klicne nastavitve</string>
|
||||
<string name="call_settings_enable_calls">Vklop glasovnih in video klicev</string>
|
||||
<string name="call_settings_enable_calls_description">Če funkcijo izklopite, bodo gumbi za klice skriti, vsi dohodni klici pa se bodo tiho zavrnili.</string>
|
||||
<string name="call_settings_video_quality">Kakovost videa</string>
|
||||
<string name="call_settings_max_bitrate">Najvišja bitna hitrost videa</string>
|
||||
<string name="call_settings_turn_servers">Strežniki TURN / STUN</string>
|
||||
@@ -952,6 +959,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="geohash_exclusive_explainer">To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli.</string>
|
||||
<string name="hashtag_exclusive">Ekskluzivna objava ključnika</string>
|
||||
<string name="hashtag_exclusive_explainer">Vidno bo le sledilcem tega ključnika. Tvoji splošni sledilci tega ne bojo videli.</string>
|
||||
<string name="long_form_reading_minutes">%1$d min branja</string>
|
||||
<string name="loading_location">Nalaganje lokacije</string>
|
||||
<string name="lack_location_permissions">Brez dovoljenj za lokacijo</string>
|
||||
<string name="add_sensitive_content_explainer">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</string>
|
||||
@@ -960,6 +968,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_description">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.</string>
|
||||
<string name="new_feature_nip17_activate">Aktiviraj</string>
|
||||
<string name="messages_create_public_chat">Javno</string>
|
||||
<string name="messages_create_group">Skupina</string>
|
||||
<string name="messages_create_public_private_chat_description">Nova javna ali zasebna skupina</string>
|
||||
<string name="messages_relay_based">Rele</string>
|
||||
<string name="messages_new_message">Zasebno</string>
|
||||
@@ -1355,6 +1364,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="private_inbox_section">Releji predala zasebnih sporočil</string>
|
||||
<string name="private_inbox_section_explainer_profile">Uporabnik prejema ZS prek teh relejev</string>
|
||||
<string name="private_inbox_section_explainer">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)</string>
|
||||
<string name="keypackages">KeyPackage-i</string>
|
||||
<string name="keypackage_section">KeyPackage Releji</string>
|
||||
<string name="keypackage_section_explainer">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.</string>
|
||||
<string name="private_outbox_section">Zasebni domači releji</string>
|
||||
<string name="private_outbox_section_explainer">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.</string>
|
||||
<string name="kind_3_section">Splošni releji</string>
|
||||
@@ -1834,7 +1846,45 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="profile_actions_dialog_title">Možnosti profila</string>
|
||||
<string name="media_actions_dialog_title">Možnosti predstavnosti</string>
|
||||
<string name="playback_actions_dialog_title">Predvajaj</string>
|
||||
<string name="video_quality_auto">Samodejno</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">Naloži HLS</string>
|
||||
<string name="share_hls_video_drawer_description">Objava HLS v več ločljivostih na medijski strežnik</string>
|
||||
<string name="hls_pick_video_primary">Izberi video</string>
|
||||
<string name="hls_pick_video_helper">Vaš video bo pripravljen v več ločljivostih, da bo predvajanje teklo gladko na vsaki povezavi.</string>
|
||||
<string name="hls_change_video">Sprememba</string>
|
||||
<string name="hls_title_label">Naslov</string>
|
||||
<string name="hls_title_placeholder">Dodajte naslov videa</string>
|
||||
<string name="hls_description_label">Opis</string>
|
||||
<string name="hls_description_placeholder">Kaj je vsebina tega videa?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Razlog (neobvezno)</string>
|
||||
<string name="hls_codec_label">Kodek</string>
|
||||
<string name="hls_codec_h265">H.265 (boljše stiskanje)</string>
|
||||
<string name="hls_codec_h264">H.264</string>
|
||||
<string name="hls_codec_fallback_notice">Naprava ne podpira H.265 — izbran bo H.264.</string>
|
||||
<string name="hls_renditions_label">Izvedbe</string>
|
||||
<string name="hls_renditions_source_format">Resolucija vira: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Ustvarilo bo: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s preskočen — zgornji vir)</string>
|
||||
<string name="hls_rendition_bitrate_kbps_format">%1$d kbps</string>
|
||||
<string name="hls_rendition_above_source">Zgornji vir — bo preskočen</string>
|
||||
<string name="hls_publish_button">Objavite HD video</string>
|
||||
<string name="hls_publishing_header_format">Objavljam “%1$s”…</string>
|
||||
<string name="hls_state_transcoding_format">Priprava videa: %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Naložite</string>
|
||||
<string name="hls_state_uploading_format">Nalagam %1$d of %2$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">Nalagam %1$s (%2$d of %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">Naloženo %1$d of %2$d</string>
|
||||
<string name="hls_state_publishing">Dogodek se objavlja…</string>
|
||||
<string name="hls_state_success_title">Video je objavljen</string>
|
||||
<string name="hls_state_success_body">Vaš HD-video je dostopen na Nostru.</string>
|
||||
<string name="hls_state_failure_title">Prišlo je do napake</string>
|
||||
<string name="hls_view_note">Poglej zapisek</string>
|
||||
<string name="hls_done">Končano</string>
|
||||
<string name="hls_try_again">Poskusi znova</string>
|
||||
<string name="hls_draft_note_after_upload">Osnutek zapiska po nalaganju</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš.</string>
|
||||
<string name="hls_draft_note_button">Osnutek zapiska</string>
|
||||
<string name="pack_actions_dialog_title">Možnosti paketa</string>
|
||||
<string name="list_actions_dialog_title">Možnosti seznama</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Možnosti zaznamkov</string>
|
||||
|
||||
@@ -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</string>
|
||||
<string name="public_chat">Publik Chat</string>
|
||||
<string name="marmot_group">MLS-grupp</string>
|
||||
<string name="public_chat_title">Metadata för offentlig chatt</string>
|
||||
<string name="public_chat_explainer">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 @@
|
||||
<string name="pictures">Bilder</string>
|
||||
<string name="shorts">Kortfilmer</string>
|
||||
<string name="longs">Videor</string>
|
||||
<string name="articles">Artiklar</string>
|
||||
<string name="private_bookmarks">Privata Bokmärken</string>
|
||||
<string name="public_bookmarks">Publika Bokmärken</string>
|
||||
<string name="add_to_private_bookmarks">Lägg till i Privata Bokmärken</string>
|
||||
@@ -396,8 +398,12 @@
|
||||
<string name="remove_from_private_bookmarks">Ta bort från Privata Bokmärken</string>
|
||||
<string name="remove_from_public_bookmarks">Ta bort från Publika Bokmärken</string>
|
||||
<string name="pinned_notes">Fästa anteckningar</string>
|
||||
<string name="pinned_notes_explainer">Dina fastnålade anteckningar</string>
|
||||
<string name="pin_to_profile">Fäst på profil</string>
|
||||
<string name="unpin_from_profile">Ta bort från profil</string>
|
||||
<string name="deleted_items_banner_title">%1$d objekt i den här listan har raderats av sina författare.</string>
|
||||
<string name="deleted_items_banner_remove">Ta bort från lista</string>
|
||||
<string name="deleted_items_banner_dismiss">Avvisa</string>
|
||||
<string name="bookmark_lists">Bokmärkeslistor</string>
|
||||
<string name="bookmark_list_icon_label">Ikon för bokmärkeslista</string>
|
||||
<string name="bookmark_list_creation_screen_title">Ny bokmärkeslista</string>
|
||||
@@ -711,6 +717,8 @@
|
||||
<string name="call_failed_accept">Kunde inte ta emot samtal</string>
|
||||
<string name="call_failed_session">Kunde inte skapa samtalssession</string>
|
||||
<string name="call_settings">Samtalsinställningar</string>
|
||||
<string name="call_settings_enable_calls">Aktivera röst- och videosamtal</string>
|
||||
<string name="call_settings_enable_calls_description">När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst.</string>
|
||||
<string name="call_settings_video_quality">Videokvalitet</string>
|
||||
<string name="call_settings_max_bitrate">Maximal videobitfrekvens</string>
|
||||
<string name="call_settings_turn_servers">TURN- / STUN-servrar</string>
|
||||
@@ -936,6 +944,7 @@
|
||||
<string name="geohash_exclusive_explainer">Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exklusivt inlägg</string>
|
||||
<string name="hashtag_exclusive_explainer">Endast anhängare av hashtaggen kommer att se den. Dina generella följare kommer inte att se den.</string>
|
||||
<string name="long_form_reading_minutes">%1$d min läsning</string>
|
||||
<string name="loading_location">Laddar position</string>
|
||||
<string name="lack_location_permissions">Inga platsbehörigheter</string>
|
||||
<string name="add_sensitive_content_explainer">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</string>
|
||||
@@ -944,6 +953,7 @@
|
||||
<string name="new_feature_nip17_might_not_be_available_description">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.</string>
|
||||
<string name="new_feature_nip17_activate">Aktivera</string>
|
||||
<string name="messages_create_public_chat">Publik</string>
|
||||
<string name="messages_create_group">Grupp</string>
|
||||
<string name="messages_create_public_private_chat_description">Ny offentlig eller privat grupp</string>
|
||||
<string name="messages_relay_based">Relä</string>
|
||||
<string name="messages_new_message">Privat</string>
|
||||
@@ -1337,6 +1347,8 @@
|
||||
<string name="private_inbox_section">DM inkorgsreläer</string>
|
||||
<string name="private_inbox_section_explainer_profile">Användaren tar emot DM:s på dessa reläer</string>
|
||||
<string name="private_inbox_section_explainer">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)</string>
|
||||
<string name="keypackage_section">KeyPackage-relays</string>
|
||||
<string name="keypackage_section_explainer">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.</string>
|
||||
<string name="private_outbox_section">Privata reläer</string>
|
||||
<string name="private_outbox_section_explainer">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.</string>
|
||||
<string name="kind_3_section">Allmänna reläer</string>
|
||||
@@ -1474,7 +1486,20 @@
|
||||
<string name="feed_group_locations">Platser</string>
|
||||
<string name="feed_group_communities">Gemenskaper</string>
|
||||
<string name="feed_group_lists">Listor</string>
|
||||
<string name="feed_group_dvms">Flödesalgoritmer</string>
|
||||
<string name="follow_list_all_favorite_dvms">Alla favorit-flödesalgoritmer</string>
|
||||
<string name="feed_group_relays">Reläer</string>
|
||||
<string name="add_dvm_to_favorites">Lägg till flödesalgoritm i favoriter</string>
|
||||
<string name="remove_dvm_from_favorites">Ta bort från favoriter</string>
|
||||
<string name="favorite_dvms_title">Favorit-flödesalgoritmer</string>
|
||||
<string name="favorite_dvms_explainer">Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler.</string>
|
||||
<string name="favorite_dvms_empty">Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här.</string>
|
||||
<string name="dvm_home_status_requesting">Frågar %1$s om ett flöde…</string>
|
||||
<string name="dvm_home_status_requesting_all">Frågar dina favorit-flödesalgoritmer om flöden…</string>
|
||||
<string name="dvm_home_status_processing">Bearbetar ditt flöde…</string>
|
||||
<string name="dvm_home_status_payment_required">Den här flödesalgoritmen kräver betalning</string>
|
||||
<string name="dvm_home_status_error">Flödesalgoritmen returnerade ett fel</string>
|
||||
<string name="dvm_home_retry">Försök igen</string>
|
||||
<string name="temporary_account">Logga ut när enheten låses</string>
|
||||
<string name="private_message">Privat meddelande</string>
|
||||
<string name="public_message">Offentligt meddelande</string>
|
||||
@@ -1818,6 +1843,40 @@
|
||||
<string name="playback_actions_dialog_title">Uppspelning</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">HLS-uppladdning</string>
|
||||
<string name="share_hls_video_drawer_description">Publicera HLS i flera upplösningar till din mediaserver</string>
|
||||
<string name="hls_pick_video_primary">Välj en video</string>
|
||||
<string name="hls_pick_video_helper">Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar.</string>
|
||||
<string name="hls_change_video">Ändra</string>
|
||||
<string name="hls_title_label">Titel</string>
|
||||
<string name="hls_title_placeholder">Ge din video en titel</string>
|
||||
<string name="hls_description_label">Beskrivning</string>
|
||||
<string name="hls_description_placeholder">Vad handlar den här videon om?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Anledning (valfritt)</string>
|
||||
<string name="hls_codec_h265">H.265 (bättre komprimering)</string>
|
||||
<string name="hls_codec_fallback_notice">H.265 inte tillgängligt på den här enheten — växlar till H.264.</string>
|
||||
<string name="hls_renditions_label">Versioner</string>
|
||||
<string name="hls_renditions_source_format">Källupplösning: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Kommer att skapa: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s hoppas över — över källan)</string>
|
||||
<string name="hls_rendition_above_source">över källa — kommer att hoppas över</string>
|
||||
<string name="hls_publish_button">Publicera HD-video</string>
|
||||
<string name="hls_publishing_header_format">Publicerar ”%1$s”…</string>
|
||||
<string name="hls_state_transcoding_format">Transkodar %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Ladda upp</string>
|
||||
<string name="hls_state_uploading_format">Laddar upp %1$d av %2$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">Laddar upp %1$s (%2$d av %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">Uppladdade %1$d av %2$d</string>
|
||||
<string name="hls_state_publishing">Publicerar händelse…</string>
|
||||
<string name="hls_state_success_title">Video publicerad</string>
|
||||
<string name="hls_state_success_body">Din HD-video är live på Nostr.</string>
|
||||
<string name="hls_state_failure_title">Något gick fel</string>
|
||||
<string name="hls_view_note">Visa anteckning</string>
|
||||
<string name="hls_done">Klar</string>
|
||||
<string name="hls_try_again">Försök igen</string>
|
||||
<string name="hls_draft_note_after_upload">Utkast till anteckning efter uppladdning</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering.</string>
|
||||
<string name="hls_draft_note_button">Utkast till anteckning</string>
|
||||
<string name="pack_actions_dialog_title">Paketåtgärder</string>
|
||||
<string name="list_actions_dialog_title">Liståtgärder</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Bokmärkesåtgärder</string>
|
||||
|
||||
@@ -1350,6 +1350,7 @@
|
||||
<string name="private_inbox_section">私信收件箱中继</string>
|
||||
<string name="private_inbox_section_explainer_profile">用户接收到这些中继的私信</string>
|
||||
<string name="private_inbox_section_explainer">设置 1 ~ 3 个中继作为您的私人收件箱。其他人将使用这些中继向您发送私信。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。示例:\n - inbox.nostr.wine(付费)\n - you.nostr1.com(个人专用中继 - 付费)</string>
|
||||
<string name="keypackages">密钥包</string>
|
||||
<string name="keypackage_section">密钥包中继</string>
|
||||
<string name="keypackage_section_explainer">发布你的 MLS 密钥包发布的中继(MIP-00)。其他用户获取这些密钥包来邀请你加入 Marmot 群聊。插入1~3个接受来自你的密钥包事件的中继并允许公开读取。</string>
|
||||
<string name="private_outbox_section">私人中继</string>
|
||||
|
||||
@@ -469,6 +469,10 @@
|
||||
<string name="pin_to_profile">Pin to Profile</string>
|
||||
<string name="unpin_from_profile">Unpin from Profile</string>
|
||||
|
||||
<string name="deleted_items_banner_title">%1$d item(s) in this list have been deleted by their author.</string>
|
||||
<string name="deleted_items_banner_remove">Remove from list</string>
|
||||
<string name="deleted_items_banner_dismiss">Dismiss</string>
|
||||
|
||||
<string name="bookmark_lists">Bookmark Lists</string>
|
||||
<string name="bookmark_list_icon_label">Icon for bookmark list</string>
|
||||
<string name="bookmark_list_creation_screen_title">New Bookmark List</string>
|
||||
@@ -1763,8 +1767,22 @@
|
||||
<string name="feed_group_locations">Locations</string>
|
||||
<string name="feed_group_communities">Communities</string>
|
||||
<string name="feed_group_lists">Lists</string>
|
||||
<string name="feed_group_dvms">Feed Algorithms</string>
|
||||
<string name="follow_list_all_favorite_dvms">All favorite feed algorithms</string>
|
||||
<string name="feed_group_relays">Relays</string>
|
||||
|
||||
<string name="add_dvm_to_favorites">Add feed algorithm to favorites</string>
|
||||
<string name="remove_dvm_from_favorites">Remove from favorites</string>
|
||||
<string name="favorite_dvms_title">Favorite Feed Algorithms</string>
|
||||
<string name="favorite_dvms_explainer">Feed algorithms you starred here appear as filter chips on the Home feed. Open Discover to add more.</string>
|
||||
<string name="favorite_dvms_empty">No favorite feed algorithms yet. Open Discover, tap one, and star it to add it here.</string>
|
||||
<string name="dvm_home_status_requesting">Asking %1$s for a feed…</string>
|
||||
<string name="dvm_home_status_requesting_all">Asking your favorite feed algorithms for feeds…</string>
|
||||
<string name="dvm_home_status_processing">Processing your feed…</string>
|
||||
<string name="dvm_home_status_payment_required">This feed algorithm requires payment</string>
|
||||
<string name="dvm_home_status_error">The feed algorithm returned an error</string>
|
||||
<string name="dvm_home_retry">Retry</string>
|
||||
|
||||
<string name="temporary_account">Log off on device lock</string>
|
||||
<string name="private_message">Private Message</string>
|
||||
<string name="public_message">Public Message</string>
|
||||
|
||||
+129
@@ -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)))
|
||||
}
|
||||
}
|
||||
+136
@@ -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 })
|
||||
}
|
||||
}
|
||||
+44
@@ -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<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
): 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<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
): 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()
|
||||
|
||||
+29
@@ -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<String>,
|
||||
deletedAddresses: Set<Address>,
|
||||
): 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+36
-2
@@ -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<String>,
|
||||
videos: Set<String>,
|
||||
pdfs: Set<String>,
|
||||
urls: Urls,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
@@ -211,7 +227,7 @@ class RichTextParser {
|
||||
|
||||
val segments = ArrayList<Segment>(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<String>,
|
||||
videos: Set<String>,
|
||||
pdfs: Set<String>,
|
||||
urls: Urls,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
@@ -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<String, String> =
|
||||
"m4a" to "audio/mp4",
|
||||
"aac" to "audio/aac",
|
||||
"flac" to "audio/flac",
|
||||
// Documents
|
||||
"pdf" to "application/pdf",
|
||||
)
|
||||
|
||||
+5
@@ -62,6 +62,11 @@ class VideoSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PdfSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(
|
||||
segment: String,
|
||||
|
||||
+69
@@ -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"))
|
||||
}
|
||||
}
|
||||
+16
@@ -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"
|
||||
|
||||
|
||||
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event?>(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<Int>,
|
||||
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)
|
||||
|
||||
-165
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+1201
File diff suppressed because it is too large
Load Diff
+24
-7
@@ -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<Workspace.WorkspaceColumn>) {
|
||||
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<String, Any?>): 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) }
|
||||
|
||||
+114
@@ -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<List<DeckColumnType>> = _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<DeckColumnType>) {
|
||||
_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<DeckColumnType> =
|
||||
LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor }
|
||||
|
||||
val DEFAULT_PINNED: List<DeckColumnType> =
|
||||
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
|
||||
}
|
||||
}
|
||||
+32
-41
@@ -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>(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),
|
||||
|
||||
+37
@@ -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>(DeckColumnType.HomeFeed)
|
||||
val currentScreen: StateFlow<DeckColumnType> = _currentScreen.asStateFlow()
|
||||
|
||||
fun navigate(type: DeckColumnType) {
|
||||
_currentScreen.value = type
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceColumn>,
|
||||
val singlePaneScreens: List<String> = emptyList(),
|
||||
) {
|
||||
data class WorkspaceColumn(
|
||||
val typeKey: String,
|
||||
val param: String? = null,
|
||||
val width: Float = 400f,
|
||||
)
|
||||
}
|
||||
|
||||
object WorkspaceIcons {
|
||||
private val icons: Map<String, ImageVector> =
|
||||
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<String> = icons.keys.sorted()
|
||||
|
||||
fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home
|
||||
}
|
||||
+227
@@ -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<List<Workspace>> = _workspaces.asStateFlow()
|
||||
|
||||
private val _activeIndex = MutableStateFlow(0)
|
||||
val activeIndex: StateFlow<Int> = _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<DeckColumn>) {
|
||||
_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<String, Any?> = mapper.readValue(json)
|
||||
val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val wsList = data["workspaces"] as? List<Map<String, Any?>> ?: 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<String>) ?: 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<String, Any?>>)?.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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
@@ -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)
|
||||
|
||||
+192
@@ -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<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun publicFavoriteAlgoFeeds(): List<AddressBookmark> = tags.mapNotNull(AddressBookmark::parse)
|
||||
|
||||
suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List<AddressBookmark>? = 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<AddressBookmark> = emptyList(),
|
||||
privateFeeds: List<AddressBookmark> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): FavoriteAlgoFeedsListEvent {
|
||||
val template = build(publicFeeds, privateFeeds, signer, createdAt)
|
||||
return signer.sign(template)
|
||||
}
|
||||
|
||||
suspend fun build(
|
||||
publicFeeds: List<AddressBookmark> = emptyList(),
|
||||
privateFeeds: List<AddressBookmark> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<FavoriteAlgoFeedsListEvent>.() -> Unit = {},
|
||||
) = eventTemplate<FavoriteAlgoFeedsListEvent>(
|
||||
kind = KIND,
|
||||
description =
|
||||
PrivateTagsInContent.encryptNip44(
|
||||
privateFeeds.map { it.toTagArray() }.toTypedArray(),
|
||||
signer,
|
||||
),
|
||||
createdAt = createdAt,
|
||||
) {
|
||||
alt(ALT)
|
||||
favoriteAlgoFeeds(publicFeeds)
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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<FavoriteAlgoFeedsListEvent>.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray())
|
||||
|
||||
fun TagArrayBuilder<FavoriteAlgoFeedsListEvent>.favoriteAlgoFeeds(apps: List<AddressBookmark>) = addAll(apps.map { it.toTagArray() })
|
||||
+28
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
+159
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user