Merge branch 'main' into claude/badge-system-amethyst-J07UM

This commit is contained in:
Vitor Pamplona
2026-04-19 17:41:02 -04:00
committed by GitHub
89 changed files with 6837 additions and 480 deletions
+2
View File
@@ -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)
@@ -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)
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet
class FavoriteAlgoFeedsListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteAlgoFeedsListEvent>(signer)
fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet()
suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet()
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class FavoriteAlgoFeedsListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: FavoriteAlgoFeedsListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress())
fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey)
fun getFavoriteAlgoFeedsListFlow(): StateFlow<NoteState> = favoriteAlgoFeedsListNote.flow().metadata.stateFlow
fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent
suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set<Address> {
val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList
return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<Address>> =
getFavoriteAlgoFeedsListFlow()
.transformLatest { noteState ->
emit(favoriteAlgoFeedsListWithBackup(noteState.note))
}.onStart {
emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowNotes: StateFlow<List<AddressableNote>> =
flow
.map { addresses ->
addresses.map { cache.getOrCreateAddressableNote(it) }
}.onStart {
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent {
val list = getFavoriteAlgoFeedsList()
return if (list == null) {
FavoriteAlgoFeedsListEvent.create(dvm, false, signer)
} else {
FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer)
}
}
suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? {
val list = getFavoriteAlgoFeedsList() ?: return null
return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer)
}
init {
settings.backupFavoriteAlgoFeedsList?.let { event ->
Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start")
getFavoriteAlgoFeedsListFlow().collect {
Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" }
(it.note.event as? FavoriteAlgoFeedsListEvent)?.let {
settings.updateFavoriteAlgoFeedsListTo(it)
}
}
}
}
}
@@ -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)
}
}
@@ -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)
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
/**
* Feed flow that merges snapshots from every currently-favorited DVM into a
* single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the
* favorite set changes.
*/
class AllFavoriteAlgoFeedsFlow(
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
val orchestrator: FavoriteAlgoFeedsOrchestrator,
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
private fun resolveContentRelays(
outbox: Set<NormalizedRelayUrl>,
proxy: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
private fun merge(
snapshots: List<FavoriteAlgoFeedsSnapshot>,
contentRelays: Set<NormalizedRelayUrl>,
): AllFavoriteAlgoFeedsTopNavFilter {
val ids = mutableSetOf<String>()
val addresses = mutableSetOf<String>()
val listen = mutableSetOf<NormalizedRelayUrl>()
val requestIds = mutableSetOf<String>()
snapshots.forEach { snap ->
ids += snap.ids
addresses += snap.addresses
listen += snap.responseRelays
snap.requestId?.let { requestIds += it }
}
return AllFavoriteAlgoFeedsTopNavFilter(
acceptedIds = ids,
acceptedAddresses = addresses,
contentRelays = contentRelays,
listenRelays = listen,
requestIds = requestIds,
)
}
private fun emptyFilter(contentRelays: Set<NormalizedRelayUrl>): AllFavoriteAlgoFeedsTopNavFilter =
AllFavoriteAlgoFeedsTopNavFilter(
acceptedIds = emptySet(),
acceptedAddresses = emptySet(),
contentRelays = contentRelays,
listenRelays = emptySet(),
requestIds = emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
override fun flow(): Flow<IFeedTopNavFilter> =
favoriteAlgoFeedAddresses.flatMapLatest { addresses ->
if (addresses.isEmpty()) {
combine(outboxRelays, proxyRelays) { outbox, proxy ->
emptyFilter(resolveContentRelays(outbox, proxy))
}
} else {
val snapshotFlows: List<Flow<FavoriteAlgoFeedsSnapshot>> = addresses.map { orchestrator.observe(it) }
combine(snapshotFlows) { it.toList() }
.let { merged ->
combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy ->
merge(snaps, resolveContentRelays(outbox, proxy))
}
}
}
}
override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter {
val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value)
val addresses = favoriteAlgoFeedAddresses.value
return if (addresses.isEmpty()) {
emptyFilter(contentRelays)
} else {
merge(addresses.map { orchestrator.observe(it).value }, contentRelays)
}
}
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Top-nav filter that unions the latest kind-6300 responses from every currently
* favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check
* against a snapshot), but the accepted set is the union across N DVMs and the
* request-id list carries one entry per DVM for the relay-listen subscription.
*/
@Immutable
class AllFavoriteAlgoFeedsTopNavFilter(
val acceptedIds: Set<HexKey>,
val acceptedAddresses: Set<String>,
val contentRelays: Set<NormalizedRelayUrl>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestIds: Set<HexKey>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean =
noteEvent.id in acceptedIds ||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
FavoriteAlgoFeedTopNavPerRelayFilterSet(
contentFetches =
contentRelays.associateWith {
FavoriteAlgoFeedTopNavPerRelayFilter(
ids = acceptedIds,
addresses = acceptedAddresses,
)
},
listenRelays = listenRelays,
requestIds = requestIds,
)
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
class FavoriteAlgoFeedFlow(
val feedAddress: Address,
val orchestrator: FavoriteAlgoFeedsOrchestrator,
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
private fun resolveRelays(
outbox: Set<NormalizedRelayUrl>,
proxy: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
private fun buildFilter(
snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot,
contentRelays: Set<NormalizedRelayUrl>,
) = FavoriteAlgoFeedTopNavFilter(
feedAddress = feedAddress,
acceptedIds = snapshot.ids,
acceptedAddresses = snapshot.addresses,
contentRelays = contentRelays,
listenRelays = snapshot.responseRelays,
requestId = snapshot.requestId,
)
override fun flow(): Flow<IFeedTopNavFilter> =
combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy ->
buildFilter(snap, resolveRelays(outbox, proxy))
}
override fun startValue(): FavoriteAlgoFeedTopNavFilter =
buildFilter(
snapshot = orchestrator.observe(feedAddress).value,
contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Top-nav filter backed by the latest kind-6300 response from a favorite algo feed.
*
* The filter is a pure immutable membership check: [match] accepts a note only if the
* DVM's latest response included it. When a new response arrives, a new instance is
* emitted through [FavoriteAlgoFeedFlow] and replaces the active filter.
*/
@Immutable
class FavoriteAlgoFeedTopNavFilter(
val feedAddress: Address,
val acceptedIds: Set<HexKey>,
val acceptedAddresses: Set<String>,
val contentRelays: Set<NormalizedRelayUrl>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestId: HexKey?,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean =
noteEvent.id in acceptedIds ||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
FavoriteAlgoFeedTopNavPerRelayFilterSet(
contentFetches =
contentRelays.associateWith {
FavoriteAlgoFeedTopNavPerRelayFilter(
ids = acceptedIds,
addresses = acceptedAddresses,
)
},
listenRelays = listenRelays,
requestIds = setOfNotNull(requestId),
)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Immutable
class FavoriteAlgoFeedTopNavPerRelayFilter(
val ids: Set<HexKey>,
val addresses: Set<String>,
) : IFeedTopNavPerRelayFilter
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Two relay sets, two distinct subscriptions:
*
* - [contentFetches] — for each user-configured content relay, the ids/addresses
* we want to pull (the actual notes the DVM curated).
* - [listenRelays] — the union of DVM publish relays across all active DVMs
* (where they will deliver future kind 6300 / 7000 events for their requests).
* - [requestIds] — the set of currently-active kind-5300 request ids to listen
* for. A single feed carries one; the merged "All favorite algo feeds"
* filter carries one per favorite algo feed.
*/
class FavoriteAlgoFeedTopNavPerRelayFilterSet(
val contentFetches: Map<NormalizedRelayUrl, FavoriteAlgoFeedTopNavPerRelayFilter>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestIds: Set<HexKey>,
) : IFeedTopNavPerRelayFilterSet
@@ -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")
@@ -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)
}
@@ -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))
@@ -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)
@@ -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
}
@@ -535,6 +535,7 @@ private fun RenderImageOrVideo(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
fullResolution = true,
)
}
@@ -593,6 +594,7 @@ private fun RenderImageOrVideo(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
fullResolution = true,
)
}
@@ -77,6 +77,8 @@ import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.request.ImageRequest
import coil3.size.Size
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -86,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
}
}
}
}
@@ -0,0 +1,300 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages.
private const val THUMBNAIL_MAX_DIM_PX = 1600
data class PdfPreview(
val thumbnail: Bitmap,
val pageCount: Int,
val aspectRatio: Float,
)
private sealed class PdfLoadState {
data object Loading : PdfLoadState()
data class Ready(
val preview: PdfPreview,
) : PdfLoadState()
data object Failed : PdfLoadState()
}
@Composable
fun PdfPreviewCard(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
) {
val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) }
if (showPdf.value) {
LoadedPdfPreviewCard(content, accountViewModel, onOpen)
} else {
PlaceholderPdfCard(content) { showPdf.value = true }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LoadedPdfPreviewCard(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
) {
val sharePopupExpanded = remember { mutableStateOf(false) }
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val targetWidthPx =
remember(density, configuration) {
val screenPx =
with(density) {
configuration.screenWidthDp.dp
.toPx()
.toInt()
}
screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1)
}
@Suppress("ProduceStateDoesNotAssignValue")
val state by produceState<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) {
value =
try {
PdfFetcher
.fetchSnapshot(content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}.use { snapshot ->
withContext(Dispatchers.IO) {
renderFirstPage(snapshot.data.toFile(), targetWidthPx)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
PdfLoadState.Failed
}
}
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
val filename = remember(content.url) { extractFilename(content.url) }
when (val current = state) {
is PdfLoadState.Loading -> {
PdfSkeletonCard(filename)
}
is PdfLoadState.Failed -> {
ClickableUrl(urlText = content.url, url = content.url)
}
is PdfLoadState.Ready -> {
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = onOpen,
onLongClick = { sharePopupExpanded.value = true },
),
) {
Image(
bitmap = current.preview.thumbnail.asImageBitmap(),
contentDescription = content.description ?: filename,
contentScale = ContentScale.FillWidth,
filterQuality = FilterQuality.High,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
)
FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount))
Spacer(modifier = DoubleVertSpacer)
}
}
}
}
@Composable
private fun PlaceholderPdfCard(
content: MediaUrlPdf,
onLoad: () -> Unit,
) {
val filename = remember(content.url) { extractFilename(content.url) }
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.fillMaxWidth()
.combinedClickable(onClick = onLoad, onLongClick = onLoad),
) {
FilenameRow(filename = filename, subtitle = "Tap to load PDF")
Spacer(modifier = DoubleVertSpacer)
}
}
@Composable
private fun PdfSkeletonCard(filename: String) {
Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) {
FilenameRow(filename = filename, subtitle = "Loading…")
Spacer(modifier = DoubleVertSpacer)
}
}
@Composable
private fun FilenameRow(
filename: String,
subtitle: String,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Outlined.PictureAsPdf,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
private fun renderFirstPage(
file: java.io.File,
targetWidthPx: Int,
): PdfLoadState =
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
PdfRenderer(pfd).use { renderer ->
val pageCount = renderer.pageCount
if (pageCount <= 0) return@use PdfLoadState.Failed
renderer.openPage(0).use { page ->
val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx)
// PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output.
val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
PdfLoadState.Ready(
PdfPreview(
thumbnail = bitmap,
pageCount = pageCount,
aspectRatio = page.width.toFloat() / page.height.toFloat(),
),
)
}
}
}
/**
* Returns the bitmap dimensions to render a PDF page at, scaled so the longest side equals
* [targetDim] while preserving aspect ratio. Always scales, never returns native size: a PDF
* page's native width/height are in PostScript points (1/72"), which is far below any useful
* display resolution. Since PDFs are vector, rendering at a larger target is essentially free
* and avoids a 72-DPI-blurry bitmap.
*/
internal fun cappedRenderSize(
pageWidth: Int,
pageHeight: Int,
targetDim: Int,
): Pair<Int, Int> {
if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1
val longest = maxOf(pageWidth, pageHeight)
val scale = targetDim.toFloat() / longest
val w = (pageWidth * scale).toInt().coerceAtLeast(1)
val h = (pageHeight * scale).toInt().coerceAtLeast(1)
return w to h
}
internal fun extractFilename(url: String): String {
val afterQuery = url.substringBefore('?').substringBefore('#')
val name = afterQuery.substringAfterLast('/', afterQuery)
return if (name.isBlank()) url else name
}
internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
@@ -0,0 +1,415 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.toggleScale
import net.engawapg.lib.zoomable.zoomable
// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when
// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes
// of RAM (PdfRenderer requires ARGB_8888). 3072 gives ~26 MB per A4-shaped page.
private const val VIEWER_MAX_DIM_PX = 3072
// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in
// past HI_RES_ZOOM_THRESHOLD we re-render the current page at
// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within
// common GPU texture limits (so drawing stays hardware-accelerated) and caps
// memory at ~48 MB for an A4-shaped page. Above 4096 most mid-range GPUs fall
// back to software rendering, which is what caused the pan/zoom jitter.
private const val HI_RES_MAX_DIM_PX = 4096
private const val HI_RES_ZOOM_THRESHOLD = 1.5f
private const val HI_RES_DEBOUNCE_MS = 200L
// Zoom level the viewer animates to when the user double-taps. Matches the
// threshold region where we swap in the hi-res bitmap.
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
// How many recently-rendered pages to keep around. Pager already pre-composes the
// current page plus one neighbor; this just speeds up small back/forward swipes.
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
private const val PAGE_CACHE_SIZE = 3
private class PageBitmapCache(
private val maxSize: Int,
) {
private val cache =
object : java.util.LinkedHashMap<Int, Bitmap>(maxSize, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<Int, Bitmap>): Boolean = size > maxSize
}
@Synchronized fun get(key: Int): Bitmap? = cache[key]
@Synchronized fun put(
key: Int,
value: Bitmap,
) {
cache[key] = value
}
}
private class PdfDocumentHandle(
val snapshot: DiskCache.Snapshot,
val pfd: ParcelFileDescriptor,
val renderer: PdfRenderer,
) {
// Snapshot eagerly. Compose's saveable PagerState reads pageCount during
// teardown, which can run *after* close() — so we can't query the renderer
// lazily without tripping IllegalStateException("Document already closed").
val pageCount: Int = renderer.pageCount
val mutex: Mutex = Mutex()
@Volatile var closed: Boolean = false
private set
fun close() {
closed = true
runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) }
runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) }
runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) }
}
}
@Composable
fun PdfViewerDialog(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
PdfViewerContent(
content = content,
accountViewModel = accountViewModel,
onDismiss = onDismiss,
)
}
}
}
@Composable
private fun PdfViewerContent(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
value =
try {
withContext(Dispatchers.IO) {
val snapshot =
PdfFetcher.fetchSnapshot(content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}
try {
val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
val renderer = PdfRenderer(pfd)
PdfDocumentHandle(snapshot, pfd, renderer)
} catch (t: Throwable) {
runCatching { snapshot.close() }
throw t
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e)
null
}
}
// Capture the handle as a local val so the onDispose lambda closes *this* handle,
// not whatever the delegated property reads at dispose time. Without this, the
// DisposableEffect keyed on handleState runs its onDispose when handleState
// transitions from null -> handle, and `handleState?.close()` reads the new handle
// and closes it right after it was created.
val handleForDispose = handleState
DisposableEffect(handleForDispose) {
onDispose {
handleForDispose?.close()
}
}
val sharePopupExpanded = remember { mutableStateOf(false) }
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
val handle = handleState
if (handle == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
} else if (handle.pageCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Unable to open PDF",
color = Color.White,
)
}
} else {
val pagerState = rememberPagerState { handle.pageCount }
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { pageIndex ->
PdfPageView(
handle = handle,
pageIndex = pageIndex,
cache = pageCache,
)
}
Row(
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.statusBarsPadding()
.systemBarsPadding()
.padding(horizontal = Size15dp, vertical = Size10dp),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
color = Color.White,
modifier =
Modifier
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
.padding(horizontal = Size10dp, vertical = Size5dp),
)
Spacer(modifier = Modifier.weight(1f))
OutlinedButton(
onClick = { sharePopupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
}
}
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun PdfPageView(
handle: PdfDocumentHandle,
pageIndex: Int,
cache: PageBitmapCache,
) {
val cached = cache.get(pageIndex)
@Suppress("ProduceStateDoesNotAssignValue")
val baseBitmap by produceState<Bitmap?>(initialValue = cached, key1 = handle, key2 = pageIndex) {
if (value != null) return@produceState
val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX)
rendered?.let { cache.put(pageIndex, it) }
value = rendered
}
val zoomState = rememberZoomState()
// Re-render the page at a higher resolution once the user zooms in and settles,
// so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base
// bitmap. Released when zoom drops back under threshold or the page leaves view.
var hiResBitmap by remember(handle, pageIndex) { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(handle, pageIndex, baseBitmap) {
if (baseBitmap == null) return@LaunchedEffect
snapshotFlow { zoomState.scale }
.debounce(HI_RES_DEBOUNCE_MS)
.distinctUntilChanged()
.collectLatest { scale ->
if (scale < HI_RES_ZOOM_THRESHOLD) {
hiResBitmap = null
} else {
val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX)
// Skip if the hi-res render wouldn't beat what we already have.
val base = baseBitmap ?: return@collectLatest
val baseLongest = maxOf(base.width, base.height)
if (target <= baseLongest) {
hiResBitmap = null
} else {
hiResBitmap = renderPageCatching(handle, pageIndex, target)
}
}
}
}
val current = hiResBitmap ?: baseBitmap
// Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new
// one and push Compose into thinking the texture changed.
val imageBitmap = remember(current) { current?.asImageBitmap() }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (imageBitmap != null) {
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = ContentScale.Fit,
// Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every
// frame during pan/zoom, which is the main source of jitter when the
// source bitmap is several megapixels. Bilinear on a 3072-4096 px source
// looks effectively identical on-screen.
filterQuality = FilterQuality.Medium,
modifier =
Modifier
.fillMaxSize()
.zoomable(
zoomState = zoomState,
onDoubleTap = { position ->
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
},
),
)
} else {
CircularProgressIndicator(color = Color.White)
}
}
}
private suspend fun renderPageCatching(
handle: PdfDocumentHandle,
pageIndex: Int,
maxDim: Int,
): Bitmap? =
try {
handle.mutex.withLock {
if (handle.closed) {
null
} else {
withContext(Dispatchers.IO) {
handle.renderer.openPage(pageIndex).use { page ->
val (width, height) = cappedRenderSize(page.width, page.height, maxDim)
// PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently
// rejected and produces blank output.
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bmp.eraseColor(android.graphics.Color.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bmp
}
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e)
null
}
@@ -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()
@@ -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,
@@ -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))
}
}
}
@@ -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 -> {
@@ -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()
@@ -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,
@@ -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 -> {
@@ -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,
)
}
},
)
@@ -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
}
}
}
@@ -21,19 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@Composable
fun DvmTopBar(
@@ -41,7 +47,7 @@ fun DvmTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
TopBarExtensibleWithBackButton(
MyExtensibleTopAppBar(
title = {
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
if (appDefinitionNote != null) {
@@ -82,6 +88,27 @@ fun DvmTopBar(
}
}
},
popBack = nav::popBack,
navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } },
actions = {
// The route passes the event's hex id, so LoadNote returns a plain Note,
// not the AddressableNote the toggle needs. Derive the AddressableNote
// from the loaded AppDefinitionEvent's address() once the event exists.
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
if (appDefinitionNote != null) {
val addressableNote by
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
(note.event as? AppDefinitionEvent)?.let {
LocalCache.getOrCreateAddressableNote(it.address())
}
}
addressableNote?.let { target ->
FavoriteAlgoFeedToggle(
appDefinitionNote = target,
accountViewModel = accountViewModel,
)
}
}
}
},
)
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
/**
* Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM.
*
* Uses [ClickableBox] (no [androidx.compose.material3.IconButton] padding) so it
* fits in a `LeftPictureLayout` title row alongside other compact reactions
* without inflating the row to 48dp.
*
* Hidden until the underlying [AppDefinitionEvent] loads and we can confirm the
* DVM advertises kind 5300 favouriting any other DVM type would only stall on
* a 6300 reply that never comes.
*/
@Composable
fun FavoriteAlgoFeedToggle(
appDefinitionNote: AddressableNote,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
iconSizeModifier: Modifier = Size20Modifier,
) {
val supportsContentDiscovery by
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
(note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true
}
if (!supportsContentDiscovery) return
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow
.collectAsStateWithLifecycle()
val isFavorite = favorites.contains(appDefinitionNote.address)
ClickableBox(
modifier = modifier,
onClick = {
if (isFavorite) {
accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address)
} else {
accountViewModel.followFavoriteAlgoFeed(
AddressBookmark(
address = appDefinitionNote.address,
relayHint = appDefinitionNote.relayHintUrl(),
),
)
}
},
) {
if (isFavorite) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
imageVector = Icons.Outlined.StarBorder,
contentDescription = stringRes(R.string.add_dvm_to_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FavoriteAlgoFeedsListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringRes(R.string.favorite_dvms_title),
popBack = nav::popBack,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 16.dp,
top = padding.calculateTopPadding(),
end = 16.dp,
bottom = padding.calculateBottomPadding(),
).consumeWindowInsets(padding),
) {
Text(
text = stringRes(R.string.favorite_dvms_explainer),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
FavoriteAlgoFeedList(accountViewModel, nav)
}
}
}
@Composable
private fun FavoriteAlgoFeedList(
accountViewModel: AccountViewModel,
nav: INav,
) {
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes
.collectAsStateWithLifecycle()
if (favorites.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.favorite_dvms_empty),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
return
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = FeedPadding,
) {
items(
items = favorites,
key = { it.address.toValue() },
) { feedNote ->
FavoriteAlgoFeedRow(
feedNote = feedNote,
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) },
onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FavoriteAlgoFeedRow(
feedNote: AddressableNote,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
val card = observeAppDefinition(feedNote, accountViewModel)
Row(
modifier =
Modifier
.fillMaxWidth()
.combinedClickable(onClick = onOpen)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
card.cover?.let { cover ->
Box(contentAlignment = BottomStart) {
MyAsyncImage(
imageUrl = cover,
contentDescription = card.name,
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = SimpleImage35Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = {
feedNote.author?.let { author ->
BannerImage(author, SimpleImage35Modifier, accountViewModel)
}
},
onError = {
feedNote.author?.let { author ->
BannerImage(author, SimpleImage35Modifier, accountViewModel)
}
},
)
}
} ?: run {
feedNote.author?.let { author ->
BannerImage(author, SimpleImage35Modifier, accountViewModel)
}
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = card.name.ifBlank { feedNote.dTag() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge,
)
card.description?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = StdVertSpacer)
Text(
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.grayText,
style = MaterialTheme.typography.bodyMedium,
)
}
}
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
)
}
}
}
@@ -0,0 +1,264 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@Composable
fun HomeAlgoFeedStatusBanner(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val topFilter by accountViewModel.account.settings.defaultHomeFollowList
.collectAsStateWithLifecycle()
when (val filter = topFilter) {
is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier)
is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier)
else -> Unit
}
}
@Composable
private fun SingleAlgoFeedBanner(
favFeed: TopFilter.FavoriteAlgoFeed,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator
.observe(favFeed.address)
.collectAsStateWithLifecycle()
// Hide the banner when the feed is already populated.
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
val feedAddressValue = favFeed.address.toValue()
LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote ->
val resolvedName by
observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note ->
(note.event as? AppDefinitionEvent)
?.appMetaData()
?.name
?.takeIf { it.isNotBlank() }
?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag()
?: ""
}
BannerCard(modifier) {
val status = snapshot.latestStatus?.status()
when {
snapshot.errorMessage != null -> {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_error),
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
}
status?.code == "payment-required" -> {
BannerMessageRow(
message =
status.description.ifBlank {
stringRes(R.string.dvm_home_status_payment_required)
},
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
var statusOverride by remember { mutableStateOf<String?>(null) }
val msg = statusOverride
if (msg != null) {
Text(text = msg, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = StdVertSpacer)
}
snapshot.latestStatus?.let {
DvmPaymentActions(
latestStatus = it,
accountViewModel = accountViewModel,
nav = nav,
onStatusUpdate = { statusOverride = it },
)
}
}
status?.code == "error" -> {
BannerMessageRow(
message =
status.description.ifBlank {
stringRes(R.string.dvm_home_status_error)
},
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
}
status?.code == "processing" -> {
BannerMessageRow(
message =
status.description.ifBlank {
stringRes(R.string.dvm_home_status_processing)
},
showSpinner = true,
)
}
else -> {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_requesting, resolvedName),
showSpinner = true,
)
}
}
}
}
}
@Composable
private fun AllFavoriteAlgoFeedsBanner(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow
.collectAsStateWithLifecycle()
if (addresses.isEmpty()) return
// Observe each DVM's snapshot so we can decide whether to hide the banner
// based on the aggregate state. Hide it as soon as any DVM has produced a
// feed; only error out when every one of them has errored.
val snapshots: List<FavoriteAlgoFeedsSnapshot> =
addresses.map { address ->
val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator
.observe(address)
.collectAsStateWithLifecycle()
snap
}
val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() }
if (anyResponded) return
val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" }
BannerCard(modifier) {
if (allErrored) {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_error),
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
RetryButton {
addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
}
} else {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_requesting_all),
showSpinner = true,
)
}
}
}
@Composable
private fun BannerCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
// Sits above the feed instead of in the topBar Column, so adding/removing
// the banner doesn't shift the tabs/filter up and down. tonalElevation
// gives it a faint surface tint so it reads as a floating overlay.
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 4.dp,
) {
Column(modifier = Modifier.padding(12.dp)) { content() }
}
}
@Composable
private fun BannerMessageRow(
message: String,
showSpinner: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
if (showSpinner) {
LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp)
Spacer(modifier = StdHorzSpacer)
}
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun RetryButton(onClick: () -> Unit) {
OutlinedButton(onClick = onClick) {
Text(stringRes(R.string.dvm_home_retry))
}
}
@@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
@@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -209,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(
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
@@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager(
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
/**
* Builds relay REQ filters for a favorite-DVM home feed.
*
* Two distinct subscription kinds with two distinct relay sets:
*
* - **Content fetch** for each of the user's outbox/proxy relays, request the
* note IDs and addressable references the DVM curated. Notes typically live on
* the user's normal relays, so this is where we fetch them.
*
* - **Response listen** for each relay the DVM advertised (where it received
* the kind-5300 request and will publish its 6300/7000 reply), subscribe to
* future kind 6300 / 7000 events tagged with the request id. The DVM almost
* never publishes responses on the user's outbox, so listening anywhere else
* would silently miss them.
*/
fun filterHomePostsByAlgoFeedIds(
set: FavoriteAlgoFeedTopNavPerRelayFilterSet,
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
@Suppress("UNUSED_PARAMETER") defaultSince: Long?,
): List<RelayBasedFilter> {
val out = mutableListOf<RelayBasedFilter>()
set.contentFetches.forEach { (relay, filter) ->
out += contentFetchFilters(relay, filter)
}
if (set.requestIds.isNotEmpty()) {
val requestIds = set.requestIds.toList()
set.listenRelays.forEach { relay ->
out += responseListenFilter(relay, requestIds)
}
}
return out
}
private fun contentFetchFilters(
relay: NormalizedRelayUrl,
filter: FavoriteAlgoFeedTopNavPerRelayFilter,
): List<RelayBasedFilter> {
val out = mutableListOf<RelayBasedFilter>()
if (filter.ids.isNotEmpty()) {
out +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
ids = filter.ids.toList(),
limit = filter.ids.size,
),
)
}
if (filter.addresses.isNotEmpty()) {
out +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = mapOf("a" to filter.addresses.toList()),
limit = filter.addresses.size,
),
)
}
return out
}
private fun responseListenFilter(
relay: NormalizedRelayUrl,
requestIds: List<HexKey>,
) = RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds =
listOf(
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
),
tags = mapOf("e" to requestIds),
limit = 10 * requestIds.size,
),
)
@@ -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,
)
}
}
}
}
@@ -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,
@@ -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,
@@ -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,
@@ -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 13 relé, která budou sloužit jako vaše soukromá schránka. Ostatní použijí tato relé k posílání DM zpráv vám. DM schránka relé by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené)</string>
<string name="keypackage_section">KeyPackage relays</string>
<string name="keypackage_section_explainer">Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 13 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení.</string>
<string name="private_outbox_section">Soukromá relé</string>
<string name="private_outbox_section_explainer">Vložte mezi 13 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele.</string>
<string name="kind_3_section">Obecná relé</string>
@@ -1475,7 +1487,20 @@
<string name="feed_group_locations">Lokace</string>
<string name="feed_group_communities">Komunity</string>
<string name="feed_group_lists">Seznamy</string>
<string name="feed_group_dvms">Algoritmy zdrojů</string>
<string name="follow_list_all_favorite_dvms">Všechny oblíbené algoritmy zdrojů</string>
<string name="feed_group_relays">Relé</string>
<string name="add_dvm_to_favorites">Přidat algoritmus zdroje k oblíbeným</string>
<string name="remove_dvm_from_favorites">Odebrat z oblíbených</string>
<string name="favorite_dvms_title">Oblíbené algoritmy zdrojů</string>
<string name="favorite_dvms_explainer">Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších.</string>
<string name="favorite_dvms_empty">Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou.</string>
<string name="dvm_home_status_requesting">Žádost %1$s o zdroj…</string>
<string name="dvm_home_status_requesting_all">Žádost oblíbených algoritmů o zdroje…</string>
<string name="dvm_home_status_processing">Zpracování zdroje…</string>
<string name="dvm_home_status_payment_required">Tento algoritmus zdroje vyžaduje platbu</string>
<string name="dvm_home_status_error">Algoritmus zdroje vrátil chybu</string>
<string name="dvm_home_retry">Zkusit znovu</string>
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
<string name="private_message">Soukromá zpráva</string>
<string name="public_message">Veřejná zpráva</string>
@@ -1819,6 +1844,41 @@
<string name="playback_actions_dialog_title">Přehrávání</string>
<string name="video_quality_auto">Auto</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS nahrávání</string>
<string name="share_hls_video_drawer_description">Publikujte multi-rozlišení HLS na váš media server</string>
<string name="hls_pick_video_primary">Vyberte video</string>
<string name="hls_pick_video_helper">Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení.</string>
<string name="hls_change_video">Změnit</string>
<string name="hls_title_label">Titulek</string>
<string name="hls_title_placeholder">Zadejte titulek videa</string>
<string name="hls_description_label">Popis</string>
<string name="hls_description_placeholder">O čem je toto video?</string>
<string name="hls_content_warning_reason_placeholder">Důvod (nepovinné)</string>
<string name="hls_codec_label">Kodek</string>
<string name="hls_codec_h265">H.265 (lepší komprese)</string>
<string name="hls_codec_fallback_notice">H.265 není na tomto zařízení dostupný — používá se H.264.</string>
<string name="hls_renditions_label">Rozlišení</string>
<string name="hls_renditions_source_format">Zdrojové rozlišení: %1$d×%2$d</string>
<string name="hls_renditions_produce_format">Bude vytvořeno: %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s přeskočeno — nad zdrojem)</string>
<string name="hls_rendition_above_source">nad zdrojem — bude přeskočeno</string>
<string name="hls_publish_button">Publikovat HD video</string>
<string name="hls_publishing_header_format">Publikování „%1$s“…</string>
<string name="hls_state_transcoding_format">Překódování %1$s</string>
<string name="hls_state_uploading_idle">Nahrát</string>
<string name="hls_state_uploading_format">Nahrávání %1$d z %2$d</string>
<string name="hls_state_uploading_with_label_format">Nahrávání %1$s (%2$d z %3$d)</string>
<string name="hls_state_uploaded_format">Nahráno %1$d z %2$d</string>
<string name="hls_state_publishing">Publikování události…</string>
<string name="hls_state_success_title">Video publikováno</string>
<string name="hls_state_success_body">Vaše HD video je publikováno na Nostr.</string>
<string name="hls_state_failure_title">Něco se pokazilo</string>
<string name="hls_view_note">Zobrazit poznámku</string>
<string name="hls_done">Hotovo</string>
<string name="hls_try_again">Zkusit znovu</string>
<string name="hls_draft_note_after_upload">Vytvořit poznámku po nahrání</string>
<string name="hls_draft_note_after_upload_explainer">Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním.</string>
<string name="hls_draft_note_button">Vytvořit poznámku</string>
<string name="pack_actions_dialog_title">Akce balíčku</string>
<string name="list_actions_dialog_title">Akce seznamu</string>
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
@@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich</string>
Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig
und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string>
<string name="public_chat">Öffentlicher Chat</string>
<string name="marmot_group">MLS-Gruppe</string>
<string name="public_chat_title">Öffentliche Chat Metadaten</string>
<string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder
kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen.
@@ -395,6 +396,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="pictures">Bilder</string>
<string name="shorts">Kurzvideos</string>
<string name="longs">Videos</string>
<string name="articles">Artikel</string>
<string name="private_bookmarks">Private Lesezeichen</string>
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
@@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich</string>
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
<string name="remove_from_public_bookmarks">Aus den öffentlichen Lesezeichen entfernen</string>
<string name="pinned_notes">Angeheftete Notizen</string>
<string name="pinned_notes_explainer">Deine angepinnten Notizen</string>
<string name="pin_to_profile">An Profil anheften</string>
<string name="unpin_from_profile">Von Profil lösen</string>
<string name="deleted_items_banner_title">%1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht.</string>
<string name="deleted_items_banner_remove">Aus Liste entfernen</string>
<string name="deleted_items_banner_dismiss">Verwerfen</string>
<string name="bookmark_lists">Lesezeichenlisten</string>
<string name="bookmark_list_icon_label">Symbol für Lesezeichenliste</string>
<string name="bookmark_list_creation_screen_title">Neue Lesezeichenliste</string>
@@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="call_failed_accept">Anruf konnte nicht angenommen werden</string>
<string name="call_failed_session">Anrufsitzung konnte nicht erstellt werden</string>
<string name="call_settings">Anrufeinstellungen</string>
<string name="call_settings_enable_calls">Sprach- und Videoanrufe aktivieren</string>
<string name="call_settings_enable_calls_description">Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert.</string>
<string name="call_settings_video_quality">Videoqualität</string>
<string name="call_settings_max_bitrate">Maximale Video-Bitrate</string>
<string name="call_settings_turn_servers">TURN- / STUN-Server</string>
@@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="geohash_exclusive_explainer">Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen.</string>
<string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string>
<string name="hashtag_exclusive_explainer">Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen.</string>
<string name="long_form_reading_minutes">%1$d Min. Lesezeit</string>
<string name="loading_location">Standort wird geladen</string>
<string name="lack_location_permissions">Keine Standortberechtigungen</string>
<string name="add_sensitive_content_explainer">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten</string>
@@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="new_feature_nip17_might_not_be_available_description">Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet.</string>
<string name="new_feature_nip17_activate">Aktivieren</string>
<string name="messages_create_public_chat">Öffentlich</string>
<string name="messages_create_group">Gruppe</string>
<string name="messages_create_public_private_chat_description">Neue öffentliche oder private Gruppe</string>
<string name="messages_relay_based">Relais</string>
<string name="messages_new_message">Privat</string>
@@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="private_inbox_section">DM-Posteingangsrelais</string>
<string name="private_inbox_section_explainer_profile">Der Benutzer empfängt Direktnachrichten auf diesen Relays</string>
<string name="private_inbox_section_explainer">Fügen Sie 13 Relais ein, die als Ihr privater Posteingang dienen sollen. Andere werden diese Relais verwenden, um Ihnen DMs zu senden. DM-Posteingangsrelais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt)</string>
<string name="keypackage_section">KeyPackage-Relays</string>
<string name="keypackage_section_explainer">Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 13 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben.</string>
<string name="private_outbox_section">Private Relais</string>
<string name="private_outbox_section_explainer">Fügen Sie zwischen 13 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen.</string>
<string name="kind_3_section">Allgemeine Relais</string>
@@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich</string>
<string name="feed_group_locations">Standorte</string>
<string name="feed_group_communities">Gemeinschaften</string>
<string name="feed_group_lists">Listen</string>
<string name="feed_group_dvms">Feed-Algorithmen</string>
<string name="follow_list_all_favorite_dvms">Alle bevorzugten Feed-Algorithmen</string>
<string name="feed_group_relays">Relais</string>
<string name="add_dvm_to_favorites">Feed-Algorithmus zu Favoriten hinzufügen</string>
<string name="remove_dvm_from_favorites">Aus Favoriten entfernen</string>
<string name="favorite_dvms_title">Bevorzugte Feed-Algorithmen</string>
<string name="favorite_dvms_explainer">Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen.</string>
<string name="favorite_dvms_empty">Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen.</string>
<string name="dvm_home_status_requesting">Frage %1$s nach einem Feed…</string>
<string name="dvm_home_status_requesting_all">Frage deine bevorzugten Feed-Algorithmen nach Feeds…</string>
<string name="dvm_home_status_processing">Feed wird verarbeitet…</string>
<string name="dvm_home_status_payment_required">Dieser Feed-Algorithmus erfordert eine Zahlung</string>
<string name="dvm_home_status_error">Der Feed-Algorithmus hat einen Fehler zurückgegeben</string>
<string name="dvm_home_retry">Wiederholen</string>
<string name="temporary_account">Beim Sperren des Geräts abmelden</string>
<string name="private_message">Private Nachricht</string>
<string name="public_message">Öffentliche Nachricht</string>
@@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich</string>
<string name="playback_actions_dialog_title">Wiedergabe</string>
<string name="video_quality_auto">Auto</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS-Upload</string>
<string name="share_hls_video_drawer_description">Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver</string>
<string name="hls_pick_video_primary">Video auswählen</string>
<string name="hls_pick_video_helper">Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten.</string>
<string name="hls_change_video">Ändern</string>
<string name="hls_title_label">Titel</string>
<string name="hls_title_placeholder">Gib deinem Video einen Titel</string>
<string name="hls_description_label">Beschreibung</string>
<string name="hls_description_placeholder">Worum geht es in diesem Video?</string>
<string name="hls_content_warning_reason_placeholder">Grund (optional)</string>
<string name="hls_codec_h265">H.265 (bessere Komprimierung)</string>
<string name="hls_codec_fallback_notice">H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264.</string>
<string name="hls_renditions_label">Versionen</string>
<string name="hls_renditions_source_format">Quellauflösung: %1$d×%2$d</string>
<string name="hls_renditions_produce_format">Wird erstellt: %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s übersprungen — über Quelle)</string>
<string name="hls_rendition_above_source">über Quelle — wird übersprungen</string>
<string name="hls_publish_button">HD-Video veröffentlichen</string>
<string name="hls_publishing_header_format">Veröffentliche „%1$s“…</string>
<string name="hls_state_transcoding_format">Transkodiere %1$s</string>
<string name="hls_state_uploading_idle">Hochladen</string>
<string name="hls_state_uploading_format">Lade %1$d von %2$d hoch</string>
<string name="hls_state_uploading_with_label_format">Lade %1$s hoch (%2$d von %3$d)</string>
<string name="hls_state_uploaded_format">%1$d von %2$d hochgeladen</string>
<string name="hls_state_publishing">Veröffentliche Ereignis…</string>
<string name="hls_state_success_title">Video veröffentlicht</string>
<string name="hls_state_success_body">Dein HD-Video ist auf Nostr live.</string>
<string name="hls_state_failure_title">Etwas ist schiefgelaufen</string>
<string name="hls_view_note">Notiz anzeigen</string>
<string name="hls_done">Fertig</string>
<string name="hls_try_again">Erneut versuchen</string>
<string name="hls_draft_note_after_upload">Notiz nach Upload entwerfen</string>
<string name="hls_draft_note_after_upload_explainer">Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst.</string>
<string name="hls_draft_note_button">Notiz entwerfen</string>
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
<string name="list_actions_dialog_title">Listenaktionen</string>
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
@@ -1350,6 +1350,7 @@
<string name="private_inbox_section">सीधा संदेश आगतपेटिका पुनःप्रसारक</string>
<string name="private_inbox_section_explainer_profile">प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है</string>
<string name="private_inbox_section_explainer">आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क)</string>
<string name="keypackages">कुंचिकापोटलियाँ</string>
<string name="keypackage_section">कुंचिकापोटली पुनःप्रसारक</string>
<string name="keypackage_section_explainer">पुनःप्रसारक जहाँ आपके एमएलएस॰ कुंचिकापोटलियाँ प्रकाशित होंगे (मिप॰००)। अन्य प्रयोक्ता इन कुंचिकापोटलियों को प्राप्त करेंगे आपको आमन्त्रित करने के लिए मार्मोट॰ समूह चर्चाओं में। १ - ३ पुनःप्रसारकों को जोडें जो आप से कुंचिकापोटली घटनाएँ स्वीकारते हों तथा सार्वजनिक पठन की अनुमती देते हों।</string>
<string name="private_outbox_section">निजी पुनःप्रसारक</string>
@@ -1851,6 +1852,25 @@
<string name="hls_renditions_source_format">स्रोत सुलझाव : %1$dघ्न%2$d</string>
<string name="hls_renditions_produce_format">उत्पाद्य : %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s त्यक्त - स्रोत ऊपर)</string>
<string name="hls_rendition_bitrate_kbps_format">%1$d सहस्रांकप्रतिसेकण्ड</string>
<string name="hls_rendition_above_source">स्रोत ऊपर - त्यक्तव्य</string>
<string name="hls_publish_button">उच्छनिरूपण दृश्याभिलेख प्रकाशित करें</string>
<string name="hls_publishing_header_format">प्रकाशन चालू \"%1$s\"…</string>
<string name="hls_state_transcoding_format">संकुचनान्तरण चालू %1$s</string>
<string name="hls_state_uploading_idle">आरोहण</string>
<string name="hls_state_uploading_format">आरोहण चालू %2$d में से %1$d</string>
<string name="hls_state_uploading_with_label_format">आरोहण चालू %1$s (%3$d में से %2$d)</string>
<string name="hls_state_uploaded_format">आरोहण कृत %2$d में से %1$d</string>
<string name="hls_state_publishing">घटना प्रकाशन चालू…</string>
<string name="hls_state_success_title">दृश्याभिलेख प्रकाशित</string>
<string name="hls_state_success_body">आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है।</string>
<string name="hls_state_failure_title">कुछ गडबड हुआ</string>
<string name="hls_view_note">टीका देखें</string>
<string name="hls_done">हो गया</string>
<string name="hls_try_again">पुनः प्रयास करें</string>
<string name="hls_draft_note_after_upload">टीका सम्पादन आरोहण पश्चात</string>
<string name="hls_draft_note_after_upload_explainer">टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व।</string>
<string name="hls_draft_note_button">टीका सम्पादन</string>
<string name="pack_actions_dialog_title">पोटली कार्य</string>
<string name="list_actions_dialog_title">सूची कार्य</string>
<string name="bookmark_item_actions_dialog_title">स्मर्त्तव्यचिह्न कार्य</string>
@@ -1350,6 +1350,9 @@
<string name="private_inbox_section">Bejövő közvetlen üzenet-átjátszók</string>
<string name="private_inbox_section_explainer_profile">A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket</string>
<string name="private_inbox_section_explainer">Adjon hozzá 13 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős)</string>
<string name="keypackages">Kulcscsomagok</string>
<string name="keypackage_section">Kulcscsomag-átjátszók</string>
<string name="keypackage_section_explainer">Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1-3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását.</string>
<string name="private_outbox_section">Privát saját átjátszók</string>
<string name="private_outbox_section_explainer">Adjon hozzá 13 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt.</string>
<string name="kind_3_section">Általános átjátszók</string>
@@ -1346,6 +1346,7 @@
<string name="private_inbox_section">Odbiorcze transmitery DM</string>
<string name="private_inbox_section_explainer_profile">Użytkownik otrzymuje DM na tych transmiterach</string>
<string name="private_inbox_section_explainer">Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny)</string>
<string name="keypackages">Pakiety kluczy</string>
<string name="keypackage_section">Transmitery pakietu kluczy</string>
<string name="keypackage_section_explainer">Transmitery, na których publikowane są Twoje pakiety kluczy MLS (MIP-00). Inni użytkownicy pobierają te pakiety kluczy, aby zaprosić Cię do czatów grupowych w aplikacji Marmot. Wprowadź od 1 do 3 transmiterów, które akceptują zdarzenia związane z pakietami kluczy od Ciebie i zezwalają na publiczny dostęp do tych danych.</string>
<string name="private_outbox_section">Transmitery Prywatne</string>
@@ -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 13 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 13 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 13 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 13 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 13 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 13 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 13 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>
+18
View File
@@ -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>
@@ -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)))
}
}
@@ -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 })
}
}
@@ -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()
@@ -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,
)
}
}
}
@@ -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,
@@ -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",
)
@@ -62,6 +62,11 @@ class VideoSegment(
segment: String,
) : Segment(segment)
@Immutable
class PdfSegment(
segment: String,
) : Segment(segment)
@Immutable
class LinkSegment(
segment: String,
@@ -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"))
}
}
@@ -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)
@@ -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")
}
},
)
}
@@ -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) }
@@ -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
}
}
@@ -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),
@@ -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
}
@@ -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"),
),
)
}
}
@@ -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)
@@ -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()
}
}
}
@@ -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() })
@@ -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)
@@ -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")
}
}