Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
|
||||
@@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
|
||||
@@ -151,8 +154,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
|
||||
@@ -187,6 +193,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
@@ -195,6 +202,12 @@ import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
@@ -224,6 +237,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
@@ -231,6 +245,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -243,8 +258,12 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
@@ -323,6 +342,10 @@ class Account(
|
||||
val hashtagListDecryptionCache = HashtagListDecryptionCache(signer)
|
||||
val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings)
|
||||
|
||||
val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer)
|
||||
val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings)
|
||||
val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope)
|
||||
|
||||
val geohashListDecryptionCache = GeohashListDecryptionCache(signer)
|
||||
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
|
||||
|
||||
@@ -418,6 +441,8 @@ class Account(
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator,
|
||||
favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow,
|
||||
).flow
|
||||
|
||||
// App-ready Feeds
|
||||
@@ -451,6 +476,9 @@ class Account(
|
||||
val liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList)
|
||||
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
|
||||
|
||||
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
|
||||
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
|
||||
|
||||
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||
|
||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||
@@ -1013,6 +1041,12 @@ class Account(
|
||||
|
||||
suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag))
|
||||
|
||||
suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm))
|
||||
|
||||
suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm))
|
||||
|
||||
fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm)
|
||||
|
||||
suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash))
|
||||
|
||||
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
|
||||
@@ -1068,6 +1102,136 @@ class Account(
|
||||
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||
}
|
||||
|
||||
suspend fun sendBadgeDefinition(
|
||||
badgeId: String,
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
imageDim: DimensionTag?,
|
||||
description: String?,
|
||||
thumbs: List<ThumbTag> = emptyList(),
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val template =
|
||||
BadgeDefinitionEvent.build(
|
||||
badgeId = badgeId,
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
imageDimensions = imageDim,
|
||||
description = description,
|
||||
thumbs = thumbs,
|
||||
)
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
suspend fun deleteBadgeDefinition(event: BadgeDefinitionEvent) {
|
||||
if (!isWriteable()) return
|
||||
if (event.pubKey != signer.pubKey) return
|
||||
|
||||
val template = DeletionEvent.build(listOf(event))
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||
}
|
||||
|
||||
suspend fun sendBadgeAward(
|
||||
definition: BadgeDefinitionEvent,
|
||||
awardees: List<PTag>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
if (awardees.isEmpty()) return
|
||||
|
||||
val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null)
|
||||
val template = BadgeAwardEvent.build(aTag, awardees)
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
val relays =
|
||||
outboxRelays.flow.value +
|
||||
awardees
|
||||
.flatMap { cache.getOrCreateUser(it.pubKey).inboxRelays() ?: emptyList() }
|
||||
.toSet()
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
client.publish(signedEvent, relays)
|
||||
}
|
||||
|
||||
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
|
||||
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
||||
val newEvent = newNote?.event as? ProfileBadgesEvent
|
||||
if (newEvent != null) return newEvent.acceptedBadges()
|
||||
|
||||
val oldNote = cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))
|
||||
val oldEvent = oldNote?.event as? AcceptedBadgeSetEvent
|
||||
return oldEvent?.acceptedBadges() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write of the accepted-badges replaceable event so two
|
||||
* rapid toggles can't race each other into losing updates.
|
||||
*/
|
||||
private val profileBadgesMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Returns a createdAt strictly greater than whatever ProfileBadgesEvent (or
|
||||
* the legacy AcceptedBadgeSetEvent) currently sits in cache. Needed because
|
||||
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
|
||||
* strictly greater, and TimeUtils.now() has only second resolution.
|
||||
*/
|
||||
private fun nextProfileBadgesCreatedAt(): Long {
|
||||
val latest =
|
||||
maxOf(
|
||||
(cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L,
|
||||
(cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L,
|
||||
)
|
||||
return maxOf(TimeUtils.now(), latest + 1)
|
||||
}
|
||||
|
||||
suspend fun addAcceptedBadge(
|
||||
award: BadgeAwardEvent,
|
||||
definition: BadgeDefinitionEvent,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null)
|
||||
val eTag = ETag(award.id)
|
||||
|
||||
val signedEvent =
|
||||
profileBadgesMutex.withLock {
|
||||
val current = loadCurrentAcceptedBadges()
|
||||
if (current.any { it.badgeAward.eventId == award.id }) return
|
||||
val updated = current + AcceptedBadge(aTag, eTag)
|
||||
|
||||
val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt())
|
||||
val signed = signer.sign(template)
|
||||
cache.justConsumeMyOwnEvent(signed)
|
||||
signed
|
||||
}
|
||||
|
||||
client.publish(signedEvent, outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
suspend fun removeAcceptedBadge(award: BadgeAwardEvent) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val signedEvent =
|
||||
profileBadgesMutex.withLock {
|
||||
val current = loadCurrentAcceptedBadges()
|
||||
val updated = current.filterNot { it.badgeAward.eventId == award.id }
|
||||
if (updated.size == current.size) return
|
||||
|
||||
val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt())
|
||||
val signed = signer.sign(template)
|
||||
cache.justConsumeMyOwnEvent(signed)
|
||||
signed
|
||||
}
|
||||
|
||||
client.publish(signedEvent, outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
fun sendMyPublicAndPrivateOutbox(event: Event?) {
|
||||
if (event == null) return
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
@@ -1119,6 +1283,7 @@ class Account(
|
||||
headerInfo.mimeType?.let { mimeType(it) }
|
||||
headerInfo.dim?.let { dimension(it) }
|
||||
headerInfo.blurHash?.let { blurhash(it.blurhash) }
|
||||
headerInfo.thumbHash?.let { nip95thumbhash(it.thumbhash) }
|
||||
|
||||
contentWarningReason?.let { contentWarning(contentWarningReason) }
|
||||
}
|
||||
@@ -1203,16 +1368,17 @@ class Account(
|
||||
val iMetas =
|
||||
urlHeaderInfo.map {
|
||||
PictureMeta(
|
||||
it.key,
|
||||
it.value.mimeType,
|
||||
it.value.blurHash?.blurhash,
|
||||
it.value.dim,
|
||||
caption,
|
||||
it.value.hash,
|
||||
it.value.size,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
url = it.key,
|
||||
mimeType = it.value.mimeType,
|
||||
blurhash = it.value.blurHash?.blurhash,
|
||||
dimension = it.value.dim,
|
||||
alt = caption,
|
||||
hash = it.value.hash,
|
||||
size = it.value.size,
|
||||
service = null,
|
||||
fallback = emptyList(),
|
||||
annotations = emptyList(),
|
||||
thumbhash = it.value.thumbHash?.thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1255,13 +1421,14 @@ class Account(
|
||||
quotes(findNostrUris(it))
|
||||
}
|
||||
pictureIMeta(
|
||||
url,
|
||||
headerInfo.mimeType,
|
||||
headerInfo.blurHash?.blurhash,
|
||||
headerInfo.dim,
|
||||
headerInfo.hash,
|
||||
headerInfo.size,
|
||||
alt,
|
||||
url = url,
|
||||
mimeType = headerInfo.mimeType,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
dimension = headerInfo.dim,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size,
|
||||
alt = alt,
|
||||
thumbhash = headerInfo.thumbHash?.thumbhash,
|
||||
)
|
||||
// add zap splits
|
||||
// add zap raiser
|
||||
@@ -1279,6 +1446,7 @@ class Account(
|
||||
dimension = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
alt = alt,
|
||||
thumbhash = headerInfo.thumbHash?.thumbhash,
|
||||
)
|
||||
|
||||
if (headerInfo.dim.height > headerInfo.dim.width) {
|
||||
@@ -1298,6 +1466,7 @@ class Account(
|
||||
headerInfo.mimeType?.let { mimeType(it) }
|
||||
headerInfo.dim?.let { dimension(it) }
|
||||
headerInfo.blurHash?.let { blurhash(it.blurhash) }
|
||||
headerInfo.thumbHash?.let { thumbhash(it.thumbhash) }
|
||||
|
||||
originalHash?.let { originalHash(it) }
|
||||
magnetUri?.let { magnet(it) }
|
||||
@@ -2148,6 +2317,7 @@ class Account(
|
||||
dim: DimensionTag?,
|
||||
hash: String?,
|
||||
mimeType: String?,
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
val template =
|
||||
ProfileGalleryEntryEvent.build(url) {
|
||||
@@ -2156,6 +2326,7 @@ class Account(
|
||||
mimeType?.let { mimeType(it) }
|
||||
dim?.let { dimension(it) }
|
||||
blurhash?.let { blurhash(it) }
|
||||
thumbhash?.let { galleryThumbhash(it) }
|
||||
}
|
||||
|
||||
val event = signer.sign(template)
|
||||
@@ -2367,7 +2538,7 @@ class Account(
|
||||
|
||||
suspend fun requestDVMContentDiscovery(
|
||||
dvmPublicKey: User,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
|
||||
) {
|
||||
val relays = nip65RelayList.inboxFlow.value.toSet()
|
||||
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
|
||||
@@ -2377,7 +2548,7 @@ class Account(
|
||||
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
|
||||
|
||||
cache.justConsumeMyOwnEvent(request)
|
||||
onReady(request)
|
||||
onReady(request, relayList.toSet())
|
||||
delay(100)
|
||||
client.publish(request, relayList)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
@@ -125,6 +126,9 @@ sealed class TopFilter(
|
||||
@Serializable
|
||||
object Chess : TopFilter(" Chess ")
|
||||
|
||||
@Serializable
|
||||
object Mine : TopFilter(" Mine ")
|
||||
|
||||
@Serializable
|
||||
class PeopleList(
|
||||
val address: Address,
|
||||
@@ -154,6 +158,13 @@ sealed class TopFilter(
|
||||
class Relay(
|
||||
val url: String,
|
||||
) : TopFilter("Relay/$url")
|
||||
|
||||
@Serializable
|
||||
class FavoriteAlgoFeed(
|
||||
val address: Address,
|
||||
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
|
||||
|
||||
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -174,6 +185,7 @@ class AccountSettings(
|
||||
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
@@ -195,6 +207,7 @@ class AccountSettings(
|
||||
var backupChannelList: ChannelListEvent? = null,
|
||||
var backupCommunityList: CommunityListEvent? = null,
|
||||
var backupHashtagList: HashtagListEvent? = null,
|
||||
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
|
||||
var backupGeohashList: GeohashListEvent? = null,
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
var backupTrustProviderList: TrustProviderListEvent? = null,
|
||||
@@ -503,6 +516,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultBadgesFollowList(name: FeedDefinition) {
|
||||
changeDefaultBadgesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultBadgesFollowList(name: TopFilter) {
|
||||
if (defaultBadgesFollowList.value != name) {
|
||||
defaultBadgesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
@@ -718,6 +742,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
|
||||
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
|
||||
backupFavoriteAlgoFeedsList = newFavoriteDvmList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
|
||||
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
@@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.algoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
private const val RESPONSE_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a favorite algo feed's current request/response state.
|
||||
*
|
||||
* - [requestId] is the id of the most recently published kind-5300 request.
|
||||
* - [responseRelays] is the relay set the kind-5300 was sent to — the same set on
|
||||
* which the DVM will publish its 6300/7000 responses, so the home subscription
|
||||
* manager must listen there (not on the user's own outbox).
|
||||
* - [ids] and [addresses] are the note references returned by the latest kind-6300 response.
|
||||
* - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …).
|
||||
* - [errorMessage] captures any client-side failure while publishing the request.
|
||||
*/
|
||||
data class FavoriteAlgoFeedsSnapshot(
|
||||
val requestId: HexKey? = null,
|
||||
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
val ids: Set<HexKey> = emptySet(),
|
||||
val addresses: Set<String> = emptySet(),
|
||||
val latestStatus: NIP90StatusEvent? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages the NIP-90 content-discovery RPC cycle for each favorite algo feed the user
|
||||
* pins to the top-nav.
|
||||
*
|
||||
* The orchestrator is lazy: it starts a request/response cycle the first time any
|
||||
* consumer calls [observe] for a given DVM address, and keeps emitting updated
|
||||
* snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the
|
||||
* kind-5300 request (e.g. pull-to-refresh).
|
||||
*
|
||||
* This class does not own the relay subscriptions that fetch DVM responses and
|
||||
* matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the
|
||||
* user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes
|
||||
* what the relays deliver into `LocalCache`.
|
||||
*/
|
||||
class FavoriteAlgoFeedsOrchestrator(
|
||||
val account: Account,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private val flows = mutableMapOf<Address, MutableStateFlow<FavoriteAlgoFeedsSnapshot>>()
|
||||
private val jobs = mutableMapOf<Address, Job>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun observe(feedAddress: Address): StateFlow<FavoriteAlgoFeedsSnapshot> {
|
||||
flows[feedAddress]?.let { return it.asStateFlow() }
|
||||
|
||||
val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot())
|
||||
flows[feedAddress] = seed
|
||||
scope.launch { startFor(feedAddress, seed) }
|
||||
return seed.asStateFlow()
|
||||
}
|
||||
|
||||
fun refresh(feedAddress: Address) {
|
||||
val seed = flows[feedAddress] ?: return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
jobs.remove(feedAddress)?.cancel()
|
||||
}
|
||||
startFor(feedAddress, seed)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(feedAddress: Address) {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
jobs.remove(feedAddress)?.cancel()
|
||||
flows.remove(feedAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startFor(
|
||||
feedAddress: Address,
|
||||
seed: MutableStateFlow<FavoriteAlgoFeedsSnapshot>,
|
||||
) {
|
||||
val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return
|
||||
val job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
account.requestDVMContentDiscovery(user) { request, relays ->
|
||||
seed.update {
|
||||
it.copy(
|
||||
requestId = request.id,
|
||||
responseRelays = relays,
|
||||
ids = emptySet(),
|
||||
addresses = emptySet(),
|
||||
latestStatus = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val requestId = seed.value.requestId ?: return@launch
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
).collectLatest { response ->
|
||||
if (response == null) return@collectLatest
|
||||
val (eventIds, addresses) = splitInnerTags(response.innerTags())
|
||||
seed.update {
|
||||
it.copy(
|
||||
ids = eventIds,
|
||||
addresses = addresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90StatusEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90StatusEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
).collectLatest { status ->
|
||||
seed.update { it.copy(latestStatus = status) }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing arrives within RESPONSE_TIMEOUT_MS (neither a 6300
|
||||
// response nor any 7000 status), surface an error so the banner
|
||||
// can show Retry instead of spinning forever.
|
||||
launch {
|
||||
delay(RESPONSE_TIMEOUT_MS)
|
||||
val current = seed.value
|
||||
val stillWaiting =
|
||||
current.requestId == requestId &&
|
||||
current.ids.isEmpty() &&
|
||||
current.addresses.isEmpty() &&
|
||||
current.latestStatus == null &&
|
||||
current.errorMessage == null
|
||||
if (stillWaiting) {
|
||||
seed.update { it.copy(errorMessage = "timeout") }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e)
|
||||
seed.update { it.copy(errorMessage = e.message ?: "Unknown error") }
|
||||
}
|
||||
}
|
||||
|
||||
mutex.withLock { jobs[feedAddress] = job }
|
||||
}
|
||||
|
||||
private fun splitInnerTags(innerTags: List<HexKey>): Pair<Set<HexKey>, Set<String>> {
|
||||
val ids = mutableSetOf<HexKey>()
|
||||
val addresses = mutableSetOf<String>()
|
||||
innerTags.forEach { value ->
|
||||
if (value.contains(':')) {
|
||||
addresses.add(value)
|
||||
} else if (value.length == 64) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
return ids to addresses
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet
|
||||
|
||||
class FavoriteAlgoFeedsListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteAlgoFeedsListEvent>(signer)
|
||||
|
||||
fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet()
|
||||
|
||||
suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet()
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FavoriteAlgoFeedsListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: FavoriteAlgoFeedsListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress())
|
||||
|
||||
fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getFavoriteAlgoFeedsListFlow(): StateFlow<NoteState> = favoriteAlgoFeedsListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent
|
||||
|
||||
suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set<Address> {
|
||||
val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList
|
||||
return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<Address>> =
|
||||
getFavoriteAlgoFeedsListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(favoriteAlgoFeedsListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flowNotes: StateFlow<List<AddressableNote>> =
|
||||
flow
|
||||
.map { addresses ->
|
||||
addresses.map { cache.getOrCreateAddressableNote(it) }
|
||||
}.onStart {
|
||||
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent {
|
||||
val list = getFavoriteAlgoFeedsList()
|
||||
return if (list == null) {
|
||||
FavoriteAlgoFeedsListEvent.create(dvm, false, signer)
|
||||
} else {
|
||||
FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? {
|
||||
val list = getFavoriteAlgoFeedsList() ?: return null
|
||||
return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer)
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupFavoriteAlgoFeedsList?.let { event ->
|
||||
Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start")
|
||||
getFavoriteAlgoFeedsListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" }
|
||||
(it.note.event as? FavoriteAlgoFeedsListEvent)?.let {
|
||||
settings.updateFavoriteAlgoFeedsListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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) {
|
||||
@@ -89,6 +95,10 @@ class FeedTopNavFilterState(
|
||||
ChessFeedFlow(followsRelays, proxyRelays)
|
||||
}
|
||||
|
||||
TopFilter.Mine -> {
|
||||
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
|
||||
}
|
||||
|
||||
is TopFilter.Community -> {
|
||||
NoteFeedFlow(
|
||||
LocalCache
|
||||
@@ -142,6 +152,24 @@ class FeedTopNavFilterState(
|
||||
is TopFilter.Relay -> {
|
||||
RelayFeedFlow(listName.url.normalizeRelayUrl())
|
||||
}
|
||||
|
||||
is TopFilter.FavoriteAlgoFeed -> {
|
||||
FavoriteAlgoFeedFlow(
|
||||
feedAddress = listName.address,
|
||||
orchestrator = favoriteAlgoFeedsOrchestrator,
|
||||
outboxRelays = followsRelays,
|
||||
proxyRelays = proxyRelays,
|
||||
)
|
||||
}
|
||||
|
||||
TopFilter.AllFavoriteAlgoFeeds -> {
|
||||
AllFavoriteAlgoFeedsFlow(
|
||||
favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses,
|
||||
orchestrator = favoriteAlgoFeedsOrchestrator,
|
||||
outboxRelays = followsRelays,
|
||||
proxyRelays = proxyRelays,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
||||
/**
|
||||
* Feed flow that merges snapshots from every currently-favorited DVM into a
|
||||
* single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the
|
||||
* favorite set changes.
|
||||
*/
|
||||
class AllFavoriteAlgoFeedsFlow(
|
||||
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
|
||||
val orchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
) : IFeedFlowsType {
|
||||
private fun resolveContentRelays(
|
||||
outbox: Set<NormalizedRelayUrl>,
|
||||
proxy: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
|
||||
|
||||
private fun merge(
|
||||
snapshots: List<FavoriteAlgoFeedsSnapshot>,
|
||||
contentRelays: Set<NormalizedRelayUrl>,
|
||||
): AllFavoriteAlgoFeedsTopNavFilter {
|
||||
val ids = mutableSetOf<String>()
|
||||
val addresses = mutableSetOf<String>()
|
||||
val listen = mutableSetOf<NormalizedRelayUrl>()
|
||||
val requestIds = mutableSetOf<String>()
|
||||
snapshots.forEach { snap ->
|
||||
ids += snap.ids
|
||||
addresses += snap.addresses
|
||||
listen += snap.responseRelays
|
||||
snap.requestId?.let { requestIds += it }
|
||||
}
|
||||
return AllFavoriteAlgoFeedsTopNavFilter(
|
||||
acceptedIds = ids,
|
||||
acceptedAddresses = addresses,
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = listen,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun emptyFilter(contentRelays: Set<NormalizedRelayUrl>): AllFavoriteAlgoFeedsTopNavFilter =
|
||||
AllFavoriteAlgoFeedsTopNavFilter(
|
||||
acceptedIds = emptySet(),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = emptySet(),
|
||||
requestIds = emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun flow(): Flow<IFeedTopNavFilter> =
|
||||
favoriteAlgoFeedAddresses.flatMapLatest { addresses ->
|
||||
if (addresses.isEmpty()) {
|
||||
combine(outboxRelays, proxyRelays) { outbox, proxy ->
|
||||
emptyFilter(resolveContentRelays(outbox, proxy))
|
||||
}
|
||||
} else {
|
||||
val snapshotFlows: List<Flow<FavoriteAlgoFeedsSnapshot>> = addresses.map { orchestrator.observe(it) }
|
||||
combine(snapshotFlows) { it.toList() }
|
||||
.let { merged ->
|
||||
combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy ->
|
||||
merge(snaps, resolveContentRelays(outbox, proxy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter {
|
||||
val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value)
|
||||
val addresses = favoriteAlgoFeedAddresses.value
|
||||
return if (addresses.isEmpty()) {
|
||||
emptyFilter(contentRelays)
|
||||
} else {
|
||||
merge(addresses.map { orchestrator.observe(it).value }, contentRelays)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
|
||||
collector.emit(startValue())
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Top-nav filter that unions the latest kind-6300 responses from every currently
|
||||
* favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check
|
||||
* against a snapshot), but the accepted set is the union across N DVMs and the
|
||||
* request-id list carries one entry per DVM for the relay-listen subscription.
|
||||
*/
|
||||
@Immutable
|
||||
class AllFavoriteAlgoFeedsTopNavFilter(
|
||||
val acceptedIds: Set<HexKey>,
|
||||
val acceptedAddresses: Set<String>,
|
||||
val contentRelays: Set<NormalizedRelayUrl>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestIds: Set<HexKey>,
|
||||
) : IFeedTopNavFilter {
|
||||
override fun matchAuthor(pubkey: HexKey): Boolean = true
|
||||
|
||||
override fun match(noteEvent: Event): Boolean =
|
||||
noteEvent.id in acceptedIds ||
|
||||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
|
||||
|
||||
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
|
||||
|
||||
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
contentRelays.associateWith {
|
||||
FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
ids = acceptedIds,
|
||||
addresses = acceptedAddresses,
|
||||
)
|
||||
},
|
||||
listenRelays = listenRelays,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
class FavoriteAlgoFeedFlow(
|
||||
val feedAddress: Address,
|
||||
val orchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
) : IFeedFlowsType {
|
||||
private fun resolveRelays(
|
||||
outbox: Set<NormalizedRelayUrl>,
|
||||
proxy: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
|
||||
|
||||
private fun buildFilter(
|
||||
snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot,
|
||||
contentRelays: Set<NormalizedRelayUrl>,
|
||||
) = FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = feedAddress,
|
||||
acceptedIds = snapshot.ids,
|
||||
acceptedAddresses = snapshot.addresses,
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = snapshot.responseRelays,
|
||||
requestId = snapshot.requestId,
|
||||
)
|
||||
|
||||
override fun flow(): Flow<IFeedTopNavFilter> =
|
||||
combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy ->
|
||||
buildFilter(snap, resolveRelays(outbox, proxy))
|
||||
}
|
||||
|
||||
override fun startValue(): FavoriteAlgoFeedTopNavFilter =
|
||||
buildFilter(
|
||||
snapshot = orchestrator.observe(feedAddress).value,
|
||||
contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
|
||||
)
|
||||
|
||||
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
|
||||
collector.emit(startValue())
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Top-nav filter backed by the latest kind-6300 response from a favorite algo feed.
|
||||
*
|
||||
* The filter is a pure immutable membership check: [match] accepts a note only if the
|
||||
* DVM's latest response included it. When a new response arrives, a new instance is
|
||||
* emitted through [FavoriteAlgoFeedFlow] and replaces the active filter.
|
||||
*/
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedTopNavFilter(
|
||||
val feedAddress: Address,
|
||||
val acceptedIds: Set<HexKey>,
|
||||
val acceptedAddresses: Set<String>,
|
||||
val contentRelays: Set<NormalizedRelayUrl>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestId: HexKey?,
|
||||
) : IFeedTopNavFilter {
|
||||
override fun matchAuthor(pubkey: HexKey): Boolean = true
|
||||
|
||||
override fun match(noteEvent: Event): Boolean =
|
||||
noteEvent.id in acceptedIds ||
|
||||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
|
||||
|
||||
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
|
||||
|
||||
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
contentRelays.associateWith {
|
||||
FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
ids = acceptedIds,
|
||||
addresses = acceptedAddresses,
|
||||
)
|
||||
},
|
||||
listenRelays = listenRelays,
|
||||
requestIds = setOfNotNull(requestId),
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
val ids: Set<HexKey>,
|
||||
val addresses: Set<String>,
|
||||
) : IFeedTopNavPerRelayFilter
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Two relay sets, two distinct subscriptions:
|
||||
*
|
||||
* - [contentFetches] — for each user-configured content relay, the ids/addresses
|
||||
* we want to pull (the actual notes the DVM curated).
|
||||
* - [listenRelays] — the union of DVM publish relays across all active DVMs
|
||||
* (where they will deliver future kind 6300 / 7000 events for their requests).
|
||||
* - [requestIds] — the set of currently-active kind-5300 request ids to listen
|
||||
* for. A single feed carries one; the merged "All favorite algo feeds"
|
||||
* filter carries one per favorite algo feed.
|
||||
*/
|
||||
class FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
val contentFetches: Map<NormalizedRelayUrl, FavoriteAlgoFeedTopNavPerRelayFilter>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestIds: Set<HexKey>,
|
||||
) : IFeedTopNavPerRelayFilterSet
|
||||
@@ -83,10 +83,12 @@ class ImageLoaderSetup {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
add(Base64Fetcher.Factory)
|
||||
add(BlurHashFetcher.Factory)
|
||||
add(ThumbHashFetcher.Factory)
|
||||
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
|
||||
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope))
|
||||
add(Base64Fetcher.BKeyer)
|
||||
add(BlurHashFetcher.BKeyer)
|
||||
add(ThumbHashFetcher.TKeyer)
|
||||
add(ProfilePictureFetcher.BKeyer)
|
||||
add(OkHttpFactory(callFactory))
|
||||
}.build(),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.images
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap
|
||||
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder
|
||||
|
||||
data class ThumbhashWrapper(
|
||||
val thumbhash: String,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class ThumbHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: ThumbhashWrapper,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val hash = data.thumbhash
|
||||
val platformImage = ThumbHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
|
||||
return ImageFetchResult(
|
||||
image = platformImage.toAndroidBitmap().asImage(true),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<ThumbhashWrapper> {
|
||||
override fun create(
|
||||
data: ThumbhashWrapper,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher = ThumbHashFetcher(options, data)
|
||||
}
|
||||
|
||||
object TKeyer : Keyer<ThumbhashWrapper> {
|
||||
override fun key(
|
||||
data: ThumbhashWrapper,
|
||||
options: Options,
|
||||
): String = data.thumbhash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best Coil model for a media placeholder.
|
||||
*
|
||||
* Prefers [ThumbhashWrapper] when a thumbhash is available (better quality, preserves aspect ratio
|
||||
* and alpha) and falls back to [BlurhashWrapper] when only a blurhash is present. Returns null
|
||||
* when neither is available, so callers can skip the placeholder request entirely.
|
||||
*/
|
||||
fun placeholderModel(
|
||||
thumbhash: String?,
|
||||
blurhash: String?,
|
||||
): Any? =
|
||||
when {
|
||||
!thumbhash.isNullOrEmpty() -> ThumbhashWrapper(thumbhash)
|
||||
!blurhash.isNullOrEmpty() -> BlurhashWrapper(blurhash)
|
||||
else -> null
|
||||
}
|
||||
+6
-3
@@ -68,6 +68,7 @@ fun VideoView(
|
||||
onDialog: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
val borderModifier =
|
||||
if (roundedCorner) {
|
||||
@@ -78,7 +79,7 @@ fun VideoView(
|
||||
Modifier
|
||||
}
|
||||
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel)
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -99,6 +100,7 @@ fun VideoView(
|
||||
alwaysShowVideo: Boolean = false,
|
||||
showControls: Boolean = true,
|
||||
accountViewModel: AccountViewModel,
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
val automaticallyStartPlayback =
|
||||
remember {
|
||||
@@ -107,7 +109,7 @@ fun VideoView(
|
||||
)
|
||||
}
|
||||
|
||||
if (blurhash == null) {
|
||||
if (blurhash == null && thumbhash == null) {
|
||||
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
|
||||
|
||||
val modifier =
|
||||
@@ -154,12 +156,13 @@ fun VideoView(
|
||||
}
|
||||
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
// Always displays Blurharh to avoid size flickering
|
||||
// Always displays a placeholder (thumbhash preferred, blurhash fallback) to avoid size flickering
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
contentScale,
|
||||
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
|
||||
thumbhash = thumbhash,
|
||||
)
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+6
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
@@ -97,6 +99,8 @@ class RelaySubscriptionsCoordinator(
|
||||
val shorts = ShortsFilterAssembler(client)
|
||||
val longs = LongsFilterAssembler(client)
|
||||
val articles = ArticlesFilterAssembler(client)
|
||||
val badges = BadgesFilterAssembler(client)
|
||||
val profileBadges = ProfileBadgesFilterAssembler(client)
|
||||
|
||||
// active when sending zaps via NWC
|
||||
val nwc = NWCPaymentFilterAssembler(client)
|
||||
@@ -114,6 +118,8 @@ class RelaySubscriptionsCoordinator(
|
||||
shorts,
|
||||
longs,
|
||||
articles,
|
||||
badges,
|
||||
profileBadges,
|
||||
channelFinder,
|
||||
eventFinder,
|
||||
userFinder,
|
||||
|
||||
+2
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
@@ -49,6 +50,7 @@ val RepliesAndReactionsToAddressesKinds1 =
|
||||
ZapPollEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
AttestationEvent.KIND,
|
||||
BadgeAwardEvent.KIND,
|
||||
)
|
||||
|
||||
val PostsAndChatMessagesToAddresses =
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.media.MediaDataSource
|
||||
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
||||
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -36,6 +37,7 @@ class FileHeader(
|
||||
val size: Int,
|
||||
val dim: DimensionTag?,
|
||||
val blurHash: BlurhashWrapper?,
|
||||
val thumbHash: ThumbhashWrapper? = null,
|
||||
) {
|
||||
class UnableToDownload(
|
||||
val fileUrl: String,
|
||||
@@ -71,9 +73,9 @@ class FileHeader(
|
||||
val hash = sha256(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed)
|
||||
val preview = PreviewMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed)
|
||||
|
||||
Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
Result.success(FileHeader(mimeType, hash, size, preview.dim, preview.blurhash, preview.thumbhash))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload") { "Couldn't convert image in to File Header: ${e.message}" }
|
||||
|
||||
+8
-4
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
||||
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
|
||||
@@ -45,12 +46,15 @@ data class MediaUploadResult(
|
||||
val ipfs: String? = null,
|
||||
// blurhash value for previews
|
||||
val blurHash: BlurhashWrapper? = null,
|
||||
// thumbhash value for previews (preferred over blurhash in the UI)
|
||||
val thumbHash: ThumbhashWrapper? = null,
|
||||
) {
|
||||
fun mergeLocalMetadata(localMetadata: Pair<BlurhashWrapper?, DimensionTag?>?): MediaUploadResult =
|
||||
localMetadata?.let { (blur, dim) ->
|
||||
fun mergeLocalMetadata(localMetadata: PreviewHashes?): MediaUploadResult =
|
||||
localMetadata?.let { hashes ->
|
||||
copy(
|
||||
dimension = dim ?: dimension,
|
||||
blurHash = blur ?: blurHash,
|
||||
dimension = hashes.dim ?: dimension,
|
||||
blurHash = hashes.blurhash ?: blurHash,
|
||||
thumbHash = hashes.thumbhash ?: thumbHash,
|
||||
)
|
||||
} ?: this
|
||||
}
|
||||
|
||||
+45
-25
@@ -26,11 +26,28 @@ import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
|
||||
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
|
||||
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
||||
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
object BlurhashMetadataCalculator {
|
||||
/**
|
||||
* Result of precomputing placeholder metadata during an upload. The bitmap or video thumbnail is
|
||||
* decoded exactly once and both hashes are computed from the same pixels to keep the hot upload
|
||||
* path cheap.
|
||||
*/
|
||||
data class PreviewHashes(
|
||||
val blurhash: BlurhashWrapper? = null,
|
||||
val thumbhash: ThumbhashWrapper? = null,
|
||||
val dim: DimensionTag? = null,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = PreviewHashes()
|
||||
}
|
||||
}
|
||||
|
||||
object PreviewMetadataCalculator {
|
||||
private fun isImage(mimeType: String?) = mimeType?.startsWith("image/", ignoreCase = true) == true
|
||||
|
||||
private fun isVideo(mimeType: String?) = mimeType?.startsWith("video/", ignoreCase = true) == true
|
||||
@@ -42,19 +59,11 @@ object BlurhashMetadataCalculator {
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
|
||||
private fun processImage(
|
||||
bitmap: Bitmap?,
|
||||
dimPrecomputed: DimensionTag?,
|
||||
): Pair<BlurhashWrapper?, DimensionTag?> {
|
||||
val (blur, dim) = processBitmap(bitmap)
|
||||
return blur to (dim ?: dimPrecomputed)
|
||||
}
|
||||
|
||||
fun computeFromBytes(
|
||||
data: ByteArray,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: DimensionTag?,
|
||||
): Pair<BlurhashWrapper?, DimensionTag?> =
|
||||
): PreviewHashes =
|
||||
when {
|
||||
isImage(mimeType) -> {
|
||||
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions())
|
||||
@@ -72,7 +81,7 @@ object BlurhashMetadataCalculator {
|
||||
}
|
||||
|
||||
else -> {
|
||||
null to dimPrecomputed
|
||||
PreviewHashes(dim = dimPrecomputed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +90,7 @@ object BlurhashMetadataCalculator {
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: DimensionTag? = null,
|
||||
): Pair<BlurhashWrapper?, DimensionTag?>? {
|
||||
): PreviewHashes? {
|
||||
if (!shouldAttempt(mimeType)) return null
|
||||
|
||||
return try {
|
||||
@@ -90,7 +99,7 @@ object BlurhashMetadataCalculator {
|
||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions())
|
||||
processImage(bitmap, dimPrecomputed)
|
||||
} ?: (null to dimPrecomputed)
|
||||
} ?: PreviewHashes(dim = dimPrecomputed)
|
||||
}
|
||||
|
||||
isVideo(mimeType) -> {
|
||||
@@ -108,33 +117,44 @@ object BlurhashMetadataCalculator {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e)
|
||||
Log.w("PreviewMetadataCalc", "Failed to compute metadata from uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBitmap(bitmap: Bitmap?): Pair<BlurhashWrapper?, DimensionTag?> =
|
||||
private fun processImage(
|
||||
bitmap: Bitmap?,
|
||||
dimPrecomputed: DimensionTag?,
|
||||
): PreviewHashes {
|
||||
val hashes = processBitmap(bitmap)
|
||||
return hashes.copy(dim = hashes.dim ?: dimPrecomputed)
|
||||
}
|
||||
|
||||
private fun processBitmap(bitmap: Bitmap?): PreviewHashes =
|
||||
if (bitmap != null) {
|
||||
try {
|
||||
val blurhash = BlurhashWrapper(bitmap.toBlurhash())
|
||||
blurhash to DimensionTag(bitmap.width, bitmap.height)
|
||||
val blurhash = runCatching { BlurhashWrapper(bitmap.toBlurhash()) }.getOrNull()
|
||||
val thumbhash = runCatching { ThumbhashWrapper(bitmap.toThumbhash()) }.getOrNull()
|
||||
PreviewHashes(
|
||||
blurhash = blurhash,
|
||||
thumbhash = thumbhash,
|
||||
dim = DimensionTag(bitmap.width, bitmap.height),
|
||||
)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
} else {
|
||||
null to null
|
||||
PreviewHashes.EMPTY
|
||||
}
|
||||
|
||||
private fun processRetriever(
|
||||
retriever: MediaMetadataRetriever,
|
||||
dimPrecomputed: DimensionTag?,
|
||||
): Pair<BlurhashWrapper?, DimensionTag?> {
|
||||
): PreviewHashes {
|
||||
val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed
|
||||
val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) }
|
||||
return if (dim?.hasSize() == true) {
|
||||
blurhash to dim
|
||||
} else {
|
||||
blurhash to null
|
||||
}
|
||||
val thumb = retriever.getThumbnail()
|
||||
val hashes = processBitmap(thumb)
|
||||
val finalDim = if (dim?.hasSize() == true) dim else hashes.dim
|
||||
return hashes.copy(dim = finalDim)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -28,8 +28,8 @@ import android.webkit.MimeTypeMap
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
@@ -95,7 +95,7 @@ class BlossomUploader {
|
||||
hashBytes.toHexKey() to totalBytes
|
||||
}
|
||||
|
||||
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
|
||||
val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType)
|
||||
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
checkNotNull(imageInputStream) { "Can't open the image input stream" }
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
|
||||
@@ -107,7 +107,7 @@ class Nip96Uploader {
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
|
||||
|
||||
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
|
||||
val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
checkNotNull(imageInputStream) { "Can't open the image input stream" }
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -233,6 +234,8 @@ open class EditPostViewModel : ViewModel() {
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
-2
@@ -207,14 +207,16 @@ fun ContentWarningNoteWithBigReasonPreview() {
|
||||
|
||||
@Composable
|
||||
fun BlurhashBackdrop(
|
||||
blurhash: String,
|
||||
blurhash: String?,
|
||||
description: String?,
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
DisplayBlurHash(
|
||||
blurhash = blurhash,
|
||||
description = description,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
thumbhash = thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,12 +224,13 @@ fun BlurhashBackdrop(
|
||||
fun BlurhashGridBackdrop(media: List<MediaUrlImage>) {
|
||||
AutoNonlazyGrid(media.size) { idx ->
|
||||
val item = media[idx]
|
||||
if (item.blurhash != null) {
|
||||
if (item.blurhash != null || item.thumbhash != null) {
|
||||
DisplayBlurHash(
|
||||
blurhash = item.blurhash,
|
||||
description = item.description,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
thumbhash = item.thumbhash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -535,6 +535,7 @@ private fun RenderImageOrVideo(
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
fullResolution = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -593,6 +594,7 @@ private fun RenderImageOrVideo(
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
fullResolution = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+72
-11
@@ -77,6 +77,8 @@ import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -86,13 +88,15 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard
|
||||
import com.vitorpamplona.amethyst.ui.components.pdf.PdfViewerDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
@@ -157,7 +161,7 @@ fun ZoomableContentView(
|
||||
preloadUrls = listOf(content.url),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, contentScale),
|
||||
backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
|
||||
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
|
||||
) {
|
||||
TwoSecondController(content) { controllerVisible ->
|
||||
val mainImageModifier =
|
||||
@@ -179,7 +183,7 @@ fun ZoomableContentView(
|
||||
preloadUrls = emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, contentScale),
|
||||
backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
|
||||
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
|
||||
@@ -198,6 +202,7 @@ fun ZoomableContentView(
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel,
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -237,9 +242,26 @@ fun ZoomableContentView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MediaUrlPdf -> {
|
||||
Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) {
|
||||
PdfPreviewCard(
|
||||
content = content,
|
||||
accountViewModel = accountViewModel,
|
||||
onOpen = { dialogOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
if (content is MediaUrlPdf) {
|
||||
PdfViewerDialog(
|
||||
content = content,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { dialogOpen = false },
|
||||
)
|
||||
} else {
|
||||
ZoomableImageDialog(
|
||||
imageUrl = content,
|
||||
allImages = images,
|
||||
@@ -251,6 +273,7 @@ fun ZoomableContentView(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TwoSecondController(
|
||||
@@ -271,6 +294,7 @@ fun LocalImageView(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false,
|
||||
fullResolution: Boolean = false,
|
||||
) {
|
||||
if (content.localFileExists()) {
|
||||
val showImage =
|
||||
@@ -281,10 +305,23 @@ fun LocalImageView(
|
||||
}
|
||||
|
||||
val ratio = remember(content) { content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.localFile.toString()) }
|
||||
val context = LocalContext.current
|
||||
val imageModel =
|
||||
if (fullResolution) {
|
||||
remember(content.localFile, context) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(content.localFile)
|
||||
.size(Size.ORIGINAL)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
content.localFile
|
||||
}
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { imageVisible ->
|
||||
if (imageVisible) {
|
||||
SubcomposeAsyncImage(
|
||||
model = content.localFile,
|
||||
model = imageModel,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = mainImageModifier,
|
||||
@@ -293,13 +330,14 @@ fun LocalImageView(
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading,
|
||||
-> {
|
||||
if (content.blurhash != null) {
|
||||
if (content.blurhash != null || content.thumbhash != null) {
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
contentScale,
|
||||
loadedImageModifier.aspectRatio(ratio),
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
} else {
|
||||
DisplayBlurHash(
|
||||
@@ -307,6 +345,7 @@ fun LocalImageView(
|
||||
content.description,
|
||||
contentScale,
|
||||
loadedImageModifier,
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -352,7 +391,7 @@ fun LocalImageView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
if ((content.blurhash != null || content.thumbhash != null) && ratio != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
@@ -360,6 +399,7 @@ fun LocalImageView(
|
||||
loadedImageModifier
|
||||
.aspectRatio(ratio)
|
||||
.clickable { showImage.value = true },
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
@@ -386,6 +426,7 @@ fun UrlImageView(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false,
|
||||
fullResolution: Boolean = false,
|
||||
) {
|
||||
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
|
||||
|
||||
@@ -396,10 +437,24 @@ fun UrlImageView(
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val imageModel =
|
||||
if (fullResolution) {
|
||||
remember(content.url, context) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(content.url)
|
||||
.size(Size.ORIGINAL)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
content.url
|
||||
}
|
||||
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
if (it) {
|
||||
SubcomposeAsyncImage(
|
||||
model = content.url,
|
||||
model = imageModel,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = mainImageModifier,
|
||||
@@ -408,7 +463,7 @@ fun UrlImageView(
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading,
|
||||
-> {
|
||||
if (content.blurhash != null) {
|
||||
if (content.blurhash != null || content.thumbhash != null) {
|
||||
if (ratio != null) {
|
||||
val modifier =
|
||||
if (contentScale == ContentScale.Crop) {
|
||||
@@ -422,6 +477,7 @@ fun UrlImageView(
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
modifier,
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
} else {
|
||||
DisplayBlurHash(
|
||||
@@ -429,6 +485,7 @@ fun UrlImageView(
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
loadedImageModifier,
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -463,7 +520,7 @@ fun UrlImageView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
if ((content.blurhash != null || content.thumbhash != null) && ratio != null) {
|
||||
val modifier =
|
||||
if (contentScale == ContentScale.Crop) {
|
||||
loadedImageModifier.clickable { showImage.value = true }
|
||||
@@ -476,6 +533,7 @@ fun UrlImageView(
|
||||
content.description,
|
||||
contentScale,
|
||||
modifier,
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
@@ -734,11 +792,14 @@ fun DisplayBlurHash(
|
||||
description: String?,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier,
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
if (blurhash == null) return
|
||||
val model =
|
||||
com.vitorpamplona.amethyst.service.images
|
||||
.placeholderModel(thumbhash, blurhash) ?: return
|
||||
|
||||
AsyncImage(
|
||||
model = BlurhashWrapper(blurhash),
|
||||
model = model,
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
|
||||
import coil3.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.io.IOException
|
||||
|
||||
object PdfFetcher {
|
||||
/**
|
||||
* Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is
|
||||
* responsible for closing the returned snapshot; while it's open the cache entry cannot be
|
||||
* evicted, so the underlying file stays valid for `PdfRenderer`.
|
||||
*
|
||||
* Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU
|
||||
* eviction and disk budget as images.
|
||||
*/
|
||||
suspend fun fetchSnapshot(
|
||||
url: String,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
): DiskCache.Snapshot {
|
||||
val diskCache = Amethyst.instance.diskCache
|
||||
diskCache.openSnapshot(url)?.let { return it }
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url")
|
||||
try {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
okHttpClient(url).newCall(request).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("PDF download failed: ${response.code}")
|
||||
}
|
||||
diskCache.fileSystem.write(editor.data) {
|
||||
val bytes = writeAll(response.body.source())
|
||||
if (bytes == 0L) throw IOException("PDF download failed: empty response body")
|
||||
}
|
||||
}
|
||||
|
||||
editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { editor.abort() }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages.
|
||||
private const val THUMBNAIL_MAX_DIM_PX = 1600
|
||||
|
||||
data class PdfPreview(
|
||||
val thumbnail: Bitmap,
|
||||
val pageCount: Int,
|
||||
val aspectRatio: Float,
|
||||
)
|
||||
|
||||
private sealed class PdfLoadState {
|
||||
data object Loading : PdfLoadState()
|
||||
|
||||
data class Ready(
|
||||
val preview: PdfPreview,
|
||||
) : PdfLoadState()
|
||||
|
||||
data object Failed : PdfLoadState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfPreviewCard(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) }
|
||||
|
||||
if (showPdf.value) {
|
||||
LoadedPdfPreviewCard(content, accountViewModel, onOpen)
|
||||
} else {
|
||||
PlaceholderPdfCard(content) { showPdf.value = true }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun LoadedPdfPreviewCard(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val targetWidthPx =
|
||||
remember(density, configuration) {
|
||||
val screenPx =
|
||||
with(density) {
|
||||
configuration.screenWidthDp.dp
|
||||
.toPx()
|
||||
.toInt()
|
||||
}
|
||||
screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val state by produceState<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) {
|
||||
value =
|
||||
try {
|
||||
PdfFetcher
|
||||
.fetchSnapshot(content.url) { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
|
||||
}.use { snapshot ->
|
||||
withContext(Dispatchers.IO) {
|
||||
renderFirstPage(snapshot.data.toFile(), targetWidthPx)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
|
||||
PdfLoadState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = sharePopupExpanded,
|
||||
content = content,
|
||||
onDismiss = { sharePopupExpanded.value = false },
|
||||
)
|
||||
|
||||
val filename = remember(content.url) { extractFilename(content.url) }
|
||||
|
||||
when (val current = state) {
|
||||
is PdfLoadState.Loading -> {
|
||||
PdfSkeletonCard(filename)
|
||||
}
|
||||
|
||||
is PdfLoadState.Failed -> {
|
||||
ClickableUrl(urlText = content.url, url = content.url)
|
||||
}
|
||||
|
||||
is PdfLoadState.Ready -> {
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.combinedClickable(
|
||||
onClick = onOpen,
|
||||
onLongClick = { sharePopupExpanded.value = true },
|
||||
),
|
||||
) {
|
||||
Image(
|
||||
bitmap = current.preview.thumbnail.asImageBitmap(),
|
||||
contentDescription = content.description ?: filename,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
filterQuality = FilterQuality.High,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
|
||||
)
|
||||
|
||||
FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount))
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderPdfCard(
|
||||
content: MediaUrlPdf,
|
||||
onLoad: () -> Unit,
|
||||
) {
|
||||
val filename = remember(content.url) { extractFilename(content.url) }
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onLoad, onLongClick = onLoad),
|
||||
) {
|
||||
FilenameRow(filename = filename, subtitle = "Tap to load PDF")
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfSkeletonCard(filename: String) {
|
||||
Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) {
|
||||
FilenameRow(filename = filename, subtitle = "Loading…")
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
filename: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFirstPage(
|
||||
file: java.io.File,
|
||||
targetWidthPx: Int,
|
||||
): PdfLoadState =
|
||||
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
|
||||
PdfRenderer(pfd).use { renderer ->
|
||||
val pageCount = renderer.pageCount
|
||||
if (pageCount <= 0) return@use PdfLoadState.Failed
|
||||
|
||||
renderer.openPage(0).use { page ->
|
||||
val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx)
|
||||
// PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output.
|
||||
val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
PdfLoadState.Ready(
|
||||
PdfPreview(
|
||||
thumbnail = bitmap,
|
||||
pageCount = pageCount,
|
||||
aspectRatio = page.width.toFloat() / page.height.toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bitmap dimensions to render a PDF page at, scaled so the longest side equals
|
||||
* [targetDim] while preserving aspect ratio. Always scales, never returns native size: a PDF
|
||||
* page's native width/height are in PostScript points (1/72"), which is far below any useful
|
||||
* display resolution. Since PDFs are vector, rendering at a larger target is essentially free
|
||||
* and avoids a 72-DPI-blurry bitmap.
|
||||
*/
|
||||
internal fun cappedRenderSize(
|
||||
pageWidth: Int,
|
||||
pageHeight: Int,
|
||||
targetDim: Int,
|
||||
): Pair<Int, Int> {
|
||||
if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1
|
||||
val longest = maxOf(pageWidth, pageHeight)
|
||||
val scale = targetDim.toFloat() / longest
|
||||
val w = (pageWidth * scale).toInt().coerceAtLeast(1)
|
||||
val h = (pageHeight * scale).toInt().coerceAtLeast(1)
|
||||
return w to h
|
||||
}
|
||||
|
||||
internal fun extractFilename(url: String): String {
|
||||
val afterQuery = url.substringBefore('?').substringBefore('#')
|
||||
val name = afterQuery.substringAfterLast('/', afterQuery)
|
||||
return if (name.isBlank()) url else name
|
||||
}
|
||||
|
||||
internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.toggleScale
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when
|
||||
// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes
|
||||
// of RAM (PdfRenderer requires ARGB_8888). 3072 gives ~26 MB per A4-shaped page.
|
||||
private const val VIEWER_MAX_DIM_PX = 3072
|
||||
|
||||
// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in
|
||||
// past HI_RES_ZOOM_THRESHOLD we re-render the current page at
|
||||
// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within
|
||||
// common GPU texture limits (so drawing stays hardware-accelerated) and caps
|
||||
// memory at ~48 MB for an A4-shaped page. Above 4096 most mid-range GPUs fall
|
||||
// back to software rendering, which is what caused the pan/zoom jitter.
|
||||
private const val HI_RES_MAX_DIM_PX = 4096
|
||||
private const val HI_RES_ZOOM_THRESHOLD = 1.5f
|
||||
private const val HI_RES_DEBOUNCE_MS = 200L
|
||||
|
||||
// Zoom level the viewer animates to when the user double-taps. Matches the
|
||||
// threshold region where we swap in the hi-res bitmap.
|
||||
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
|
||||
|
||||
// How many recently-rendered pages to keep around. Pager already pre-composes the
|
||||
// current page plus one neighbor; this just speeds up small back/forward swipes.
|
||||
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
|
||||
private const val PAGE_CACHE_SIZE = 3
|
||||
|
||||
private class PageBitmapCache(
|
||||
private val maxSize: Int,
|
||||
) {
|
||||
private val cache =
|
||||
object : java.util.LinkedHashMap<Int, Bitmap>(maxSize, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<Int, Bitmap>): Boolean = size > maxSize
|
||||
}
|
||||
|
||||
@Synchronized fun get(key: Int): Bitmap? = cache[key]
|
||||
|
||||
@Synchronized fun put(
|
||||
key: Int,
|
||||
value: Bitmap,
|
||||
) {
|
||||
cache[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private class PdfDocumentHandle(
|
||||
val snapshot: DiskCache.Snapshot,
|
||||
val pfd: ParcelFileDescriptor,
|
||||
val renderer: PdfRenderer,
|
||||
) {
|
||||
// Snapshot eagerly. Compose's saveable PagerState reads pageCount during
|
||||
// teardown, which can run *after* close() — so we can't query the renderer
|
||||
// lazily without tripping IllegalStateException("Document already closed").
|
||||
val pageCount: Int = renderer.pageCount
|
||||
val mutex: Mutex = Mutex()
|
||||
|
||||
@Volatile var closed: Boolean = false
|
||||
private set
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) }
|
||||
runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) }
|
||||
runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfViewerDialog(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
|
||||
PdfViewerContent(
|
||||
content = content,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfViewerContent(
|
||||
content: MediaUrlPdf,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
|
||||
value =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val snapshot =
|
||||
PdfFetcher.fetchSnapshot(content.url) { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
|
||||
}
|
||||
try {
|
||||
val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
val renderer = PdfRenderer(pfd)
|
||||
PdfDocumentHandle(snapshot, pfd, renderer)
|
||||
} catch (t: Throwable) {
|
||||
runCatching { snapshot.close() }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the handle as a local val so the onDispose lambda closes *this* handle,
|
||||
// not whatever the delegated property reads at dispose time. Without this, the
|
||||
// DisposableEffect keyed on handleState runs its onDispose when handleState
|
||||
// transitions from null -> handle, and `handleState?.close()` reads the new handle
|
||||
// and closes it right after it was created.
|
||||
val handleForDispose = handleState
|
||||
DisposableEffect(handleForDispose) {
|
||||
onDispose {
|
||||
handleForDispose?.close()
|
||||
}
|
||||
}
|
||||
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = sharePopupExpanded,
|
||||
content = content,
|
||||
onDismiss = { sharePopupExpanded.value = false },
|
||||
)
|
||||
|
||||
val handle = handleState
|
||||
if (handle == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else if (handle.pageCount == 0) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Unable to open PDF",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val pagerState = rememberPagerState { handle.pageCount }
|
||||
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { pageIndex ->
|
||||
PdfPageView(
|
||||
handle = handle,
|
||||
pageIndex = pageIndex,
|
||||
cache = pageCache,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||
color = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { sharePopupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun PdfPageView(
|
||||
handle: PdfDocumentHandle,
|
||||
pageIndex: Int,
|
||||
cache: PageBitmapCache,
|
||||
) {
|
||||
val cached = cache.get(pageIndex)
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val baseBitmap by produceState<Bitmap?>(initialValue = cached, key1 = handle, key2 = pageIndex) {
|
||||
if (value != null) return@produceState
|
||||
val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX)
|
||||
rendered?.let { cache.put(pageIndex, it) }
|
||||
value = rendered
|
||||
}
|
||||
|
||||
val zoomState = rememberZoomState()
|
||||
|
||||
// Re-render the page at a higher resolution once the user zooms in and settles,
|
||||
// so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base
|
||||
// bitmap. Released when zoom drops back under threshold or the page leaves view.
|
||||
var hiResBitmap by remember(handle, pageIndex) { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(handle, pageIndex, baseBitmap) {
|
||||
if (baseBitmap == null) return@LaunchedEffect
|
||||
snapshotFlow { zoomState.scale }
|
||||
.debounce(HI_RES_DEBOUNCE_MS)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { scale ->
|
||||
if (scale < HI_RES_ZOOM_THRESHOLD) {
|
||||
hiResBitmap = null
|
||||
} else {
|
||||
val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX)
|
||||
// Skip if the hi-res render wouldn't beat what we already have.
|
||||
val base = baseBitmap ?: return@collectLatest
|
||||
val baseLongest = maxOf(base.width, base.height)
|
||||
if (target <= baseLongest) {
|
||||
hiResBitmap = null
|
||||
} else {
|
||||
hiResBitmap = renderPageCatching(handle, pageIndex, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val current = hiResBitmap ?: baseBitmap
|
||||
// Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new
|
||||
// one and push Compose into thinking the texture changed.
|
||||
val imageBitmap = remember(current) { current?.asImageBitmap() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
// Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every
|
||||
// frame during pan/zoom, which is the main source of jitter when the
|
||||
// source bitmap is several megapixels. Bilinear on a 3072-4096 px source
|
||||
// looks effectively identical on-screen.
|
||||
filterQuality = FilterQuality.Medium,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
zoomState = zoomState,
|
||||
onDoubleTap = { position ->
|
||||
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
|
||||
},
|
||||
),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun renderPageCatching(
|
||||
handle: PdfDocumentHandle,
|
||||
pageIndex: Int,
|
||||
maxDim: Int,
|
||||
): Bitmap? =
|
||||
try {
|
||||
handle.mutex.withLock {
|
||||
if (handle.closed) {
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.IO) {
|
||||
handle.renderer.openPage(pageIndex).use { page ->
|
||||
val (width, height) = cappedRenderSize(page.width, page.height, maxDim)
|
||||
// PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently
|
||||
// rejected and produces blank output.
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
bmp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e)
|
||||
null
|
||||
}
|
||||
@@ -58,6 +58,7 @@ object ScrollStateKeys {
|
||||
const val POLLS_SCREEN = "PollsFeed"
|
||||
const val POLLS_OPEN = "PollsOpenFeed"
|
||||
const val POLLS_CLOSED = "PollsClosedFeed"
|
||||
const val BADGES_SCREEN = "BadgesFeed"
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||
const val SHORTS_SCREEN = "ShortsFeed"
|
||||
|
||||
@@ -64,6 +64,9 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
|
||||
@@ -95,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||
@@ -214,6 +218,9 @@ fun BuildNavigation(
|
||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
@@ -277,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) }
|
||||
|
||||
|
||||
+9
@@ -59,6 +59,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
import androidx.compose.material.icons.outlined.Photo
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
@@ -601,6 +602,14 @@ fun ListContent(
|
||||
route = Route.Polls,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.badges,
|
||||
icon = Icons.Outlined.MilitaryTech,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Badges,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.discover_marketplace,
|
||||
icon = Icons.Outlined.Storefront,
|
||||
|
||||
@@ -45,6 +45,22 @@ sealed class Route {
|
||||
|
||||
@Serializable object Polls : Route()
|
||||
|
||||
@Serializable object Badges : Route()
|
||||
|
||||
@Serializable object ProfileBadges : Route()
|
||||
|
||||
@Serializable data class AwardBadge(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
|
||||
@Serializable object Products : Route()
|
||||
@@ -171,6 +187,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
+64
-4
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ViewList
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Groups
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
@@ -67,6 +68,7 @@ import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
||||
import com.vitorpamplona.amethyst.ui.screen.HashtagName
|
||||
@@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@@ -150,23 +154,40 @@ fun FeedFilterSpinner(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Size20Modifier)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Bound the Column so long filter names (e.g. DVM titles) get truncated
|
||||
// instead of wrapping to multiple lines and shoving the expand icon out.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
val filter = selected?.code
|
||||
if (filter is TopFilter.Geohash) {
|
||||
LoadCityName(
|
||||
geohashStr = filter.tag,
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(filter.tag)
|
||||
Text(
|
||||
text = filter.tag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
Text(cityName)
|
||||
Text(
|
||||
text = cityName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(currentText)
|
||||
Text(
|
||||
text = currentText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (filter is TopFilter.AroundMe) {
|
||||
@@ -178,6 +199,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||
@@ -193,6 +216,8 @@ fun FeedFilterSpinner(
|
||||
text = "(${myLocation.geoHash})",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
@@ -203,6 +228,8 @@ fun FeedFilterSpinner(
|
||||
text = "($cityName)",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,6 +239,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,6 +249,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -329,6 +360,20 @@ fun RenderOption(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
is FavoriteAlgoFeedName -> {
|
||||
val noteState by observeNote(option.note, accountViewModel)
|
||||
val name =
|
||||
(noteState.note.event as? AppDefinitionEvent)
|
||||
?.appMetaData()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() } ?: option.note.dTag()
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +391,7 @@ private enum class FeedGroup(
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LOCATIONS(R.string.feed_group_locations),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
DVMS(R.string.feed_group_dvms),
|
||||
RELAYS(R.string.feed_group_relays),
|
||||
}
|
||||
|
||||
@@ -373,10 +419,15 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
|
||||
FeedGroup.LOCATIONS
|
||||
}
|
||||
|
||||
is FavoriteAlgoFeedName -> {
|
||||
FeedGroup.DVMS
|
||||
}
|
||||
|
||||
is ResourceName -> {
|
||||
when (entry.item.code) {
|
||||
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
|
||||
is TopFilter.Global -> FeedGroup.RELAYS
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
|
||||
else -> FeedGroup.FEEDS
|
||||
}
|
||||
}
|
||||
@@ -541,12 +592,21 @@ private fun FeedIcon(
|
||||
Icons.AutoMirrored.Outlined.ViewList
|
||||
}
|
||||
|
||||
is TopFilter.FavoriteAlgoFeed -> {
|
||||
Icons.Outlined.AutoAwesome
|
||||
}
|
||||
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> {
|
||||
Icons.Outlined.AutoAwesome
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (item.name) {
|
||||
is GeoHashName -> Icons.Outlined.LocationOn
|
||||
is RelayName -> Icons.Outlined.Storage
|
||||
is CommunityName -> Icons.Outlined.Groups
|
||||
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
|
||||
is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome
|
||||
else -> Icons.Outlined.Person
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
|
||||
import com.vitorpamplona.amethyst.ui.note.types.AcceptBadgeControls
|
||||
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -128,6 +130,10 @@ fun BadgeCompose(
|
||||
note.replyTo?.firstOrNull()?.let {
|
||||
BadgeDisplay(baseNote = it, accountViewModel)
|
||||
}
|
||||
|
||||
(note.event as? BadgeAwardEvent)?.let { award ->
|
||||
AcceptBadgeControls(award, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,10 +396,6 @@ fun AcceptableNote(
|
||||
}
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote = baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, nav) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
@@ -454,10 +450,6 @@ fun AcceptableNote(
|
||||
}
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
@@ -938,6 +930,10 @@ private fun RenderNoteRow(
|
||||
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+3
@@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -543,6 +544,8 @@ open class CommentPostViewModel :
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
|
||||
@@ -20,15 +20,28 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -36,109 +49,82 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
|
||||
private val BadgeCardShape = RoundedCornerShape(12.dp)
|
||||
private val BadgeThumbSize = 72.dp
|
||||
|
||||
@Composable
|
||||
fun BadgeDisplay(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
val badgeData by observeNoteEvent<BadgeDefinitionEvent>(baseNote, accountViewModel)
|
||||
val definition = badgeData ?: return
|
||||
|
||||
badgeData?.let {
|
||||
RenderBadge(
|
||||
it.image(),
|
||||
it.name(),
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.onBackground,
|
||||
it.description(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val isMine = definition.pubKey == accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RenderBadgePreview() {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
|
||||
ThemeComparisonRow {
|
||||
RenderBadge(
|
||||
image = "http://test.com",
|
||||
name = "Name",
|
||||
backgroundForRow = background,
|
||||
textColor = Color.LightGray,
|
||||
description = "This badge is awarded to the dedicated individuals who actively contributed by writing events to the relay during the crucial testing phase leading up to the first beta release of Grain.",
|
||||
)
|
||||
BadgeCard(
|
||||
imageUrl = definition.thumb()?.ifBlank { null } ?: definition.image(),
|
||||
name = definition.name(),
|
||||
description = definition.description(),
|
||||
onClick =
|
||||
nav?.let {
|
||||
{
|
||||
routeFor(baseNote, accountViewModel.account)?.let { route -> nav.nav(route) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBadge(
|
||||
image: String?,
|
||||
name: String?,
|
||||
backgroundForRow: Color,
|
||||
textColor: Color,
|
||||
description: String?,
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
) {
|
||||
Column {
|
||||
image?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
R.string.badge_award_image_for,
|
||||
name ?: "",
|
||||
if (isMine && nav != null) {
|
||||
BadgeActionRow {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
nav.nav(
|
||||
Route.AwardBadge(
|
||||
kind = definition.kind,
|
||||
pubKeyHex = definition.pubKey,
|
||||
dTag = definition.dTag(),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().background(backgroundForRow),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MilitaryTech,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(stringRes(R.string.award_badge))
|
||||
}
|
||||
|
||||
name?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,28 +141,239 @@ fun RenderBadgeAward(
|
||||
if (note.replyTo.isNullOrEmpty()) return
|
||||
|
||||
val noteEvent = note.event as? BadgeAwardEvent ?: return
|
||||
|
||||
val definitionNote = note.replyTo?.firstOrNull()
|
||||
val definition by
|
||||
if (definitionNote != null) {
|
||||
observeNoteEvent<BadgeDefinitionEvent>(definitionNote, accountViewModel)
|
||||
} else {
|
||||
remember { mutableStateOf<BadgeDefinitionEvent?>(null) }
|
||||
}
|
||||
|
||||
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
|
||||
|
||||
Text(text = stringRes(R.string.award_granted_to))
|
||||
LaunchedEffect(note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } }
|
||||
|
||||
LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } }
|
||||
BadgeCard(
|
||||
imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image(),
|
||||
name = definition?.name() ?: stringRes(R.string.award_granted_to),
|
||||
description = definition?.description(),
|
||||
onClick = {
|
||||
routeFor(note, accountViewModel.account)?.let { nav.nav(it) }
|
||||
},
|
||||
) {
|
||||
if (awardees.isNotEmpty()) {
|
||||
BadgeAwardeesRow(awardees, accountViewModel, nav)
|
||||
}
|
||||
AcceptBadgeControls(noteEvent, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
|
||||
awardees.take(100).forEach { user ->
|
||||
@Composable
|
||||
private fun BadgeCard(
|
||||
imageUrl: String?,
|
||||
name: String?,
|
||||
description: String?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
actions: @Composable () -> Unit = {},
|
||||
) {
|
||||
val baseModifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)
|
||||
OutlinedCard(
|
||||
modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier,
|
||||
shape = BadgeCardShape,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BadgeThumbnail(imageUrl, name)
|
||||
|
||||
Spacer(modifier = Modifier.size(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!description.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeThumbnail(
|
||||
imageUrl: String?,
|
||||
name: String?,
|
||||
) {
|
||||
val description =
|
||||
if (name != null) {
|
||||
stringRes(R.string.badge_award_image_for, name)
|
||||
} else {
|
||||
stringRes(R.string.badge_award_image)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(BadgeThumbSize).clip(RoundedCornerShape(10.dp)),
|
||||
) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
RobohashAsyncImage(
|
||||
robot = "badgenotfound",
|
||||
contentDescription = description,
|
||||
modifier = Modifier.size(BadgeThumbSize),
|
||||
loadRobohash = true,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = description,
|
||||
modifier = Modifier.size(BadgeThumbSize),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeActionRow(content: @Composable () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BadgeAwardeesRow(
|
||||
awardees: List<User>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.badge_awardees_label, awardees.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
awardees.take(24).forEach { user ->
|
||||
UserPicture(
|
||||
user = user,
|
||||
size = Size35dp,
|
||||
size = Size30dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
if (awardees.size > 100) {
|
||||
Text(stringRes(R.string.badge_and_n_others, awardees.size - 100), maxLines = 1)
|
||||
if (awardees.size > 24) {
|
||||
Text(
|
||||
text = stringRes(R.string.badge_and_n_others, awardees.size - 24),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
note.replyTo?.firstOrNull()?.let {
|
||||
BadgeDisplay(baseNote = it, accountViewModel)
|
||||
@Composable
|
||||
fun AcceptBadgeControls(
|
||||
award: BadgeAwardEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
val amAwardee = remember(award, myPubkey) { award.awardeeIds().contains(myPubkey) }
|
||||
if (!amAwardee) return
|
||||
|
||||
val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey))
|
||||
val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey))
|
||||
|
||||
val newState by newNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val oldState by oldNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val isAccepted =
|
||||
remember(newState, oldState, award.id) {
|
||||
val newEvent = newState.note.event as? ProfileBadgesEvent
|
||||
val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent
|
||||
val awardIds =
|
||||
newEvent?.badgeAwardEvents()?.map { it.eventId }
|
||||
?: oldEvent?.badgeAwardEvents()?.map { it.eventId }
|
||||
?: emptyList()
|
||||
awardIds.contains(award.id)
|
||||
}
|
||||
|
||||
BadgeActionRow {
|
||||
if (isAccepted) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.removeAcceptedBadge(award)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.unaccept_badge))
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.removeAcceptedBadge(award)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.reject_badge))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner
|
||||
val defNote = LocalCache.getAddressableNoteIfExists(defAddr)
|
||||
val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner
|
||||
accountViewModel.account.addAcceptedBadge(award, defEvent)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.accept_badge))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RenderBadgePreview() {
|
||||
ThemeComparisonRow {
|
||||
BadgeCard(
|
||||
imageUrl = null,
|
||||
name = "Relay Beta Tester",
|
||||
description = "Awarded to the dedicated individuals who actively contributed by writing events to the relay during the beta phase.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ fun RenderClassifieds(
|
||||
dim = it.dimension,
|
||||
uri = note.toNostrUri(),
|
||||
mimeType = it.mimeType,
|
||||
thumbhash = it.thumbhash,
|
||||
)
|
||||
}
|
||||
val title = noteEvent.title()
|
||||
|
||||
@@ -49,6 +49,7 @@ fun FileHeaderDisplay(
|
||||
val content by
|
||||
remember(note) {
|
||||
val blurHash = event.blurhash()
|
||||
val thumbHash = event.thumbhash()
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
@@ -66,6 +67,7 @@ fun FileHeaderDisplay(
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -77,6 +79,7 @@ fun FileHeaderDisplay(
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ private fun ObserverAndRenderNIP95(
|
||||
val uri = header.toNostrUri()
|
||||
val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) }
|
||||
val blurHash = eventHeader.blurhash()
|
||||
val thumbHash = eventHeader.thumbhash()
|
||||
val dimensions = eventHeader.dimensions()
|
||||
val description = eventHeader.alt() ?: eventHeader.content
|
||||
val mimeType = eventHeader.mimeType()
|
||||
@@ -90,6 +91,7 @@ private fun ObserverAndRenderNIP95(
|
||||
blurhash = blurHash,
|
||||
isVerified = true,
|
||||
uri = uri,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaLocalVideo(
|
||||
@@ -100,6 +102,8 @@ private fun ObserverAndRenderNIP95(
|
||||
isVerified = true,
|
||||
uri = uri,
|
||||
authorName = header.author?.toBestDisplayName(),
|
||||
blurhash = blurHash,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ fun PictureDisplay(
|
||||
dim = it.dimension,
|
||||
uri = uri,
|
||||
mimeType = it.mimeType,
|
||||
thumbhash = it.thumbhash,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
@@ -116,7 +117,7 @@ fun PictureDisplay(
|
||||
preloadUrls = listOf(first.url),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
|
||||
backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } },
|
||||
backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } },
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = first,
|
||||
|
||||
@@ -103,6 +103,7 @@ fun VideoDisplay(
|
||||
dim = imeta.dimension,
|
||||
uri = uri,
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -115,6 +116,7 @@ fun VideoDisplay(
|
||||
artworkUri = imeta.image.firstOrNull(),
|
||||
mimeType = imeta.mimeType,
|
||||
blurhash = imeta.blurhash,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -71,6 +71,7 @@ fun JustVideoDisplay(
|
||||
dim = imeta.dimension,
|
||||
uri = note.toNostrUri(),
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -82,6 +83,7 @@ fun JustVideoDisplay(
|
||||
uri = note.toNostrUri(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -95,7 +97,7 @@ fun JustVideoDisplay(
|
||||
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, contentScale),
|
||||
backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
|
||||
backdrop = (imeta.thumbhash ?: imeta.blurhash)?.let { { BlurhashBackdrop(imeta.blurhash, content.description, imeta.thumbhash) } },
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -96,6 +97,18 @@ class TopNavFilterState(
|
||||
name = ResourceName(R.string.follow_list_chess),
|
||||
)
|
||||
|
||||
val mineFollow =
|
||||
FeedDefinition(
|
||||
code = TopFilter.Mine,
|
||||
name = ResourceName(R.string.follow_list_mine),
|
||||
)
|
||||
|
||||
val allFavoriteAlgoFeedsFollow =
|
||||
FeedDefinition(
|
||||
code = TopFilter.AllFavoriteAlgoFeeds,
|
||||
name = ResourceName(R.string.follow_list_all_favorite_dvms),
|
||||
)
|
||||
|
||||
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||
|
||||
fun mergePeopleLists(
|
||||
@@ -140,6 +153,7 @@ class TopNavFilterState(
|
||||
geotagList: Set<String>,
|
||||
communityList: List<AddressableNote>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
favoriteAlgoFeedsList: List<AddressableNote>,
|
||||
): List<FeedDefinition> {
|
||||
val hashtags =
|
||||
hashtagList.map {
|
||||
@@ -173,7 +187,26 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
return (communities + hashtags + geotags + relays).sortedBy { it.name.name() }
|
||||
// Favorites can only be added through FavoriteAlgoFeedToggle, which itself checks
|
||||
// that the AppDefinitionEvent advertises kind 5300. Don't re-check here: on
|
||||
// cold start the AppDefinitionEvent may not be in cache yet, and dropping
|
||||
// the entry means the persisted TopFilter.FavoriteAlgoFeed can't find its chip
|
||||
// in the spinner (user sees "Select an option" while the banner fires the
|
||||
// RPC — the bug we had before this change).
|
||||
val favoriteAlgoFeeds =
|
||||
favoriteAlgoFeedsList.map { feedNote ->
|
||||
FeedDefinition(
|
||||
TopFilter.FavoriteAlgoFeed(feedNote.address),
|
||||
FavoriteAlgoFeedName(feedNote),
|
||||
)
|
||||
}
|
||||
|
||||
// Only show the "All favorite algo feeds" meta-chip when there is at least one
|
||||
// real favorite to merge; otherwise the chip opens to an empty feed.
|
||||
val allFavorites =
|
||||
if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList()
|
||||
|
||||
return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -183,6 +216,7 @@ class TopNavFilterState(
|
||||
account.geohashList.flow,
|
||||
account.communityList.flowNotes,
|
||||
account.relayFeedsList.flow,
|
||||
account.favoriteAlgoFeedsList.flowNotes,
|
||||
::mergeInterests,
|
||||
).onStart {
|
||||
emit(
|
||||
@@ -191,6 +225,7 @@ class TopNavFilterState(
|
||||
account.geohashList.flow.value,
|
||||
account.communityList.flowNotes.value,
|
||||
account.relayFeedsList.flow.value,
|
||||
account.favoriteAlgoFeedsList.flowNotes.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -211,6 +246,18 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _badgeRoutes =
|
||||
livePeopleListsFlow.transform { peopleLists ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _kind3GlobalPeople =
|
||||
livePeopleListsFlow.transform { peopleLists ->
|
||||
checkNotInMainThread()
|
||||
@@ -233,6 +280,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultLists)
|
||||
|
||||
val badgeRoutes =
|
||||
_badgeRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
fun destroy() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
@@ -298,6 +350,15 @@ class CommunityName(
|
||||
override fun name() = "/n/${(note.dTag())}"
|
||||
}
|
||||
|
||||
@Stable
|
||||
class FavoriteAlgoFeedName(
|
||||
val note: AddressableNote,
|
||||
) : Name() {
|
||||
override fun name(): String =
|
||||
(note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() }
|
||||
?: note.dTag()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class FeedDefinition(
|
||||
val code: TopFilter,
|
||||
|
||||
+7
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
|
||||
@@ -81,6 +82,8 @@ class AccountFeedContentStates(
|
||||
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
|
||||
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
||||
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
|
||||
@@ -125,6 +128,8 @@ class AccountFeedContentStates(
|
||||
openPollsFeed.updateFeedWith(newNotes)
|
||||
closedPollsFeed.updateFeedWith(newNotes)
|
||||
|
||||
badgesFeed.updateFeedWith(newNotes)
|
||||
|
||||
picturesFeed.updateFeedWith(newNotes)
|
||||
productsFeed.updateFeedWith(newNotes)
|
||||
shortsFeed.updateFeedWith(newNotes)
|
||||
@@ -163,6 +168,8 @@ class AccountFeedContentStates(
|
||||
openPollsFeed.deleteFromFeed(newNotes)
|
||||
closedPollsFeed.deleteFromFeed(newNotes)
|
||||
|
||||
badgesFeed.deleteFromFeed(newNotes)
|
||||
|
||||
picturesFeed.deleteFromFeed(newNotes)
|
||||
productsFeed.deleteFromFeed(newNotes)
|
||||
shortsFeed.deleteFromFeed(newNotes)
|
||||
|
||||
+9
-2
@@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -1106,6 +1107,12 @@ class AccountViewModel(
|
||||
|
||||
fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) }
|
||||
|
||||
fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) }
|
||||
|
||||
fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) }
|
||||
|
||||
fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm)
|
||||
|
||||
fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) }
|
||||
|
||||
fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) }
|
||||
@@ -1820,8 +1827,8 @@ class AccountViewModel(
|
||||
onReady: (event: Note) -> Unit,
|
||||
) {
|
||||
launchSigner {
|
||||
account.requestDVMContentDiscovery(dvmPublicKey) {
|
||||
onReady(LocalCache.getOrCreateNote(it.id))
|
||||
account.requestDVMContentDiscovery(dvmPublicKey) { request, _ ->
|
||||
onReady(LocalCache.getOrCreateNote(request.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
|
||||
|
||||
@Composable
|
||||
fun BadgesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
BadgesScreen(
|
||||
feedContentState = accountViewModel.feedStates.badgesFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesScreen(
|
||||
feedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedContentState)
|
||||
WatchAccountForBadgesScreen(feedContentState, accountViewModel)
|
||||
BadgesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
BadgesTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Badges, accountViewModel) { route ->
|
||||
if (route == Route.Badges) {
|
||||
feedContentState.sendToTop()
|
||||
} else {
|
||||
nav.newStack(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
NewBadgeButton(accountViewModel)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
RefresheableBox(feedContentState, true) {
|
||||
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "BadgesFeed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForBadgesScreen(
|
||||
feedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val listState by accountViewModel.account.liveBadgesFollowLists.collectAsStateWithLifecycle()
|
||||
val hiddenUsers =
|
||||
accountViewModel.account.hiddenUsers.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
|
||||
feedContentState.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun BadgesTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val list by accountViewModel.account.settings.defaultBadgesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
BadgesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultBadgesFollowList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgesTopNavFilterBar(
|
||||
followListsModel: TopNavFilterState,
|
||||
listName: TopFilter,
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val allLists by followListsModel.badgeRoutes.collectAsStateWithLifecycle()
|
||||
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = listName,
|
||||
explainer = stringRes(R.string.select_list_to_filter),
|
||||
options = allLists,
|
||||
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewBadgeButton(accountViewModel: AccountViewModel) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val postViewModel: NewBadgeModel = viewModel()
|
||||
|
||||
if (showDialog) {
|
||||
NewBadgeDialog(
|
||||
onClose = { showDialog = false },
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { showDialog = true },
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_compose, 5),
|
||||
contentDescription = stringRes(id = R.string.new_badge),
|
||||
modifier = Size26Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Composable
|
||||
fun AwardBadgeScreen(
|
||||
kind: Int,
|
||||
pubKeyHex: HexKey,
|
||||
dTag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val vm: AwardBadgeViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(accountViewModel, kind, pubKeyHex, dTag) {
|
||||
vm.init(accountViewModel, kind, pubKeyHex, dTag)
|
||||
}
|
||||
|
||||
var searchInput by remember { mutableStateOf("") }
|
||||
val selectedUsers = remember { mutableStateListOf<User>() }
|
||||
|
||||
val userSuggestions =
|
||||
remember {
|
||||
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { userSuggestions.reset() }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.award_badge,
|
||||
isActive = { vm.definition != null && selectedUsers.isNotEmpty() },
|
||||
onCancel = { nav.popBack() },
|
||||
onPost = {
|
||||
val toAward = selectedUsers.toList()
|
||||
accountViewModel.launchSigner {
|
||||
vm.sendPost(toAward)
|
||||
nav.popBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
BadgeSummary(vm)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (selectedUsers.isNotEmpty()) {
|
||||
selectedUsers.toList().forEachIndexed { index, user ->
|
||||
SelectedUserRow(
|
||||
user = user,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClear = { selectedUsers.remove(user) },
|
||||
)
|
||||
if (index < selectedUsers.lastIndex) HorizontalDivider()
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = searchInput,
|
||||
onValueChange = { newValue ->
|
||||
searchInput = newValue
|
||||
if (newValue.length > 2) {
|
||||
userSuggestions.processCurrentWord(newValue)
|
||||
} else {
|
||||
userSuggestions.reset()
|
||||
}
|
||||
},
|
||||
label = { Text(stringRes(R.string.award_badge_search_label)) },
|
||||
placeholder = { Text(stringRes(R.string.award_badge_search_placeholder)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (searchInput.length > 2) {
|
||||
ShowUserSuggestionList(
|
||||
userSuggestions = userSuggestions,
|
||||
onSelect = { user ->
|
||||
if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) {
|
||||
selectedUsers.add(user)
|
||||
}
|
||||
searchInput = ""
|
||||
userSuggestions.reset()
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeSummary(vm: AwardBadgeViewModel) {
|
||||
val def = vm.definition
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
if (def == null) {
|
||||
Text(
|
||||
text = stringRes(R.string.award_badge_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = def.name() ?: def.dTag(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
def.description()?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedUserRow(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
size = 40.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
UserSecondaryLine(user)
|
||||
}
|
||||
TextButton(onClick = onClear) {
|
||||
Text(stringRes(R.string.award_badge_remove_recipient))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserSecondaryLine(user: User) {
|
||||
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
|
||||
|
||||
val text =
|
||||
when (val state = nip05StateMetadata) {
|
||||
is Nip05State.Exists -> {
|
||||
val name = state.nip05.name
|
||||
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
user.pubkeyDisplayHex()
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
|
||||
@Stable
|
||||
class AwardBadgeViewModel : ViewModel() {
|
||||
lateinit var accountViewModel: AccountViewModel
|
||||
lateinit var account: Account
|
||||
|
||||
var definition by mutableStateOf<BadgeDefinitionEvent?>(null)
|
||||
|
||||
fun init(
|
||||
accountVM: AccountViewModel,
|
||||
kind: Int,
|
||||
pubKeyHex: HexKey,
|
||||
dTag: String,
|
||||
) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
|
||||
val ev =
|
||||
LocalCache.getAddressableNoteIfExists(Address(kind, pubKeyHex, dTag))?.event as? BadgeDefinitionEvent
|
||||
definition = ev
|
||||
}
|
||||
|
||||
suspend fun sendPost(awardees: List<User>) {
|
||||
val def = definition ?: return
|
||||
if (awardees.isEmpty()) return
|
||||
|
||||
val pTags = awardees.map { PTag(it.pubkeyHex) }
|
||||
account.sendBadgeAward(def, pTags)
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
|
||||
class BadgesFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-badges-" + followList().code
|
||||
|
||||
override fun limit() = 100
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultBadgesFollowList.value
|
||||
|
||||
fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||
|
||||
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
|
||||
|
||||
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
|
||||
|
||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||
|
||||
private fun myPubkey(): String = account.userProfile().pubkeyHex
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes =
|
||||
if (followList() == TopFilter.Mine) {
|
||||
val me = myPubkey()
|
||||
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
|
||||
val noteEvent = it.event
|
||||
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
|
||||
}
|
||||
} else {
|
||||
val params = buildFilterParams(account)
|
||||
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
|
||||
val noteEvent = it.event
|
||||
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
|
||||
}
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
if (followList() == TopFilter.Mine) {
|
||||
val me = myPubkey()
|
||||
return newItems.filterTo(HashSet()) {
|
||||
val noteEvent = it.event
|
||||
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
|
||||
}
|
||||
}
|
||||
|
||||
val params = buildFilterParams(account)
|
||||
return newItems.filterTo(HashSet()) {
|
||||
val noteEvent = it.event
|
||||
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.liveBadgesFollowLists.value,
|
||||
account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class BadgesQueryState(
|
||||
val account: Account,
|
||||
val feedStates: AccountFeedContentStates,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class BadgesFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<BadgesQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
BadgesSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun BadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
|
||||
BadgesFilterAssemblerSubscription(
|
||||
accountViewModel.dataSources().badges,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesFilterAssemblerSubscription(
|
||||
dataSource: BadgesFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
BadgesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BadgesSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<BadgesQueryState>,
|
||||
) : PerUserAndFollowListEoseManager<BadgesQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: BadgesQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val listName = key.listName()
|
||||
val defaultSince = key.feedStates.badgesFeed.lastNoteCreatedAtIfFilled()
|
||||
|
||||
return if (listName == TopFilter.Mine) {
|
||||
val outbox = key.account.outboxRelays.flow.value
|
||||
filterBadgesMine(key.account.userProfile().pubkeyHex, outbox, since)
|
||||
} else {
|
||||
makeBadgesFilter(key.followsPerRelay(), since, defaultSince)
|
||||
}
|
||||
}
|
||||
|
||||
override fun user(key: BadgesQueryState) = key.account.userProfile()
|
||||
|
||||
override fun list(key: BadgesQueryState) = key.listName()
|
||||
|
||||
fun BadgesQueryState.listNameFlow() = account.settings.defaultBadgesFollowList
|
||||
|
||||
fun BadgesQueryState.listName() = listNameFlow().value
|
||||
|
||||
fun BadgesQueryState.followsPerRelayFlow() = account.liveBadgesFollowListsPerRelay
|
||||
|
||||
fun BadgesQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: BadgesQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.listNameFlow().collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.badgesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
private const val BADGE_FEED_LIMIT = 100
|
||||
|
||||
fun makeBadgesFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
when (feedSettings) {
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterBadgesByFollows(feedSettings, since, defaultSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterBadgesByAuthors(feedSettings, since, defaultSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
fun filterBadgesMine(
|
||||
pubkey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
|
||||
val authors = listOf(pubkey)
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(BadgeDefinitionEvent.KIND),
|
||||
authors = authors,
|
||||
limit = BADGE_FEED_LIMIT,
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterBadgesByAuthorsOnRelay(
|
||||
relay: NormalizedRelayUrl,
|
||||
authors: Set<HexKey>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authors.isEmpty()) return emptyList()
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(BadgeDefinitionEvent.KIND),
|
||||
authors = authors.sorted(),
|
||||
limit = BADGE_FEED_LIMIT,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterBadgesByFollows(
|
||||
followsSet: AllFollowsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (followsSet.set.isEmpty()) return emptyList()
|
||||
return followsSet.set.flatMap {
|
||||
val sinceValue = since?.get(it.key)?.time ?: defaultSince
|
||||
val authors = it.value.authors
|
||||
if (authors == null || authors.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
filterBadgesByAuthorsOnRelay(it.key, authors, sinceValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterBadgesByAuthors(
|
||||
authorSet: AuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
return authorSet.set.flatMap {
|
||||
filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince)
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterBadgesByMutedAuthors(
|
||||
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
return authorSet.set.flatMap {
|
||||
filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince)
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterBadgesGlobal(
|
||||
relays: GlobalTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.set.isEmpty()) return emptyList()
|
||||
return relays.set.map {
|
||||
val sinceValue = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo()
|
||||
RelayBasedFilter(
|
||||
relay = it.key,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(BadgeDefinitionEvent.KIND),
|
||||
limit = BADGE_FEED_LIMIT,
|
||||
since = sinceValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewBadgeDialog(
|
||||
onClose: () -> Unit,
|
||||
postViewModel: NewBadgeModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.init(account)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
var wantsToPickImage by remember { mutableStateOf(false) }
|
||||
|
||||
if (wantsToPickImage) {
|
||||
GallerySelect(
|
||||
onImageUri = { uris ->
|
||||
wantsToPickImage = false
|
||||
postViewModel.setPickedMedia(
|
||||
if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
SetDialogToEdgeToEdge()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CreatingTopBar(
|
||||
titleRes = R.string.new_badge,
|
||||
isActive = postViewModel::canPost,
|
||||
onCancel = {
|
||||
postViewModel.cancelModel()
|
||||
onClose()
|
||||
},
|
||||
onPost = {
|
||||
postViewModel.upload(
|
||||
context,
|
||||
onSuccess = onClose,
|
||||
onError = accountViewModel.toastManager::toast,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
BadgeImagePicker(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onPickImage = { wantsToPickImage = true },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BadgeFormFields(postViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeImagePicker(
|
||||
postViewModel: NewBadgeModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onPickImage: () -> Unit,
|
||||
) {
|
||||
if (postViewModel.hasPickedImage()) {
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
// Tap the preview to swap to a different image.
|
||||
Box(modifier = Modifier.clickable(onClick = onPickImage)) {
|
||||
ShowImageUploadGallery(
|
||||
list = it,
|
||||
onDelete = { postViewModel.setPickedMedia(persistentListOf()) },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UploadPlaceholder(onClick = onPickImage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadPlaceholder(onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
).clickable(onClick = onClick)
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.badge_upload_image_cta),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.badge_upload_image_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeFormFields(
|
||||
postViewModel: NewBadgeModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
|
||||
.collectAsState()
|
||||
|
||||
val fileServerOptions =
|
||||
remember(fileServers) {
|
||||
fileServers
|
||||
.map { TitleExplainer(it.name, it.baseUrl) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.name,
|
||||
onValueChange = { postViewModel.name = it },
|
||||
label = { Text(stringRes(R.string.badge_name_label)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.badge_name_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.description,
|
||||
onValueChange = { postViewModel.description = it },
|
||||
label = { Text(stringRes(R.string.badge_description_label)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.badge_description_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
|
||||
?.name
|
||||
?: fileServers.firstOrNull()?.name
|
||||
?: DEFAULT_MEDIA_SERVERS[0].name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { postViewModel.selectedServer = fileServers[it] },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
when (postViewModel.mediaQualitySlider) {
|
||||
0 -> stringRes(R.string.media_compression_quality_low)
|
||||
1 -> stringRes(R.string.media_compression_quality_medium)
|
||||
2 -> stringRes(R.string.media_compression_quality_high)
|
||||
3 -> stringRes(R.string.media_compression_quality_uncompressed)
|
||||
else -> stringRes(R.string.media_compression_quality_medium)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = postViewModel.mediaQualitySlider.toFloat(),
|
||||
onValueChange = { postViewModel.mediaQualitySlider = it.toInt() },
|
||||
valueRange = 0f..3f,
|
||||
steps = 2,
|
||||
)
|
||||
}
|
||||
|
||||
SettingSwitchItem(
|
||||
title = R.string.strip_metadata_label,
|
||||
description = R.string.strip_metadata_description,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
checked = postViewModel.stripMetadata,
|
||||
onCheckedChange = { postViewModel.stripMetadata = it },
|
||||
)
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Drives the "new badge" creation flow: uploads the user-picked image (at the
|
||||
* chosen server / compression), then publishes a NIP-58 BadgeDefinitionEvent
|
||||
* (kind 30009) with an auto-generated UUID d-tag, the uploaded URL as both
|
||||
* `image` and `thumb`, and the user-provided name / description.
|
||||
*
|
||||
* Intentionally does NOT publish the event unless the image upload succeeds —
|
||||
* otherwise we'd announce a badge that references a URL that doesn't exist.
|
||||
*/
|
||||
@Stable
|
||||
class NewBadgeModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploading by mutableStateOf(false)
|
||||
|
||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||
var name by mutableStateOf("")
|
||||
var description by mutableStateOf("")
|
||||
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
|
||||
var stripMetadata by mutableStateOf(true)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
fun init(account: Account) {
|
||||
if (this.account == account) return
|
||||
this.account = account
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
}
|
||||
|
||||
fun load(
|
||||
account: Account,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
) {
|
||||
this.account = account
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
this.name = ""
|
||||
this.description = ""
|
||||
}
|
||||
|
||||
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
|
||||
this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
|
||||
}
|
||||
|
||||
fun hasPickedImage(): Boolean = multiOrchestrator != null
|
||||
|
||||
fun canPost(): Boolean =
|
||||
!isUploading &&
|
||||
multiOrchestrator != null &&
|
||||
selectedServer != null &&
|
||||
name.isNotBlank()
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) = try {
|
||||
uploadUnsafe(context, onSuccess, onError)
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||
)
|
||||
}
|
||||
|
||||
private fun uploadUnsafe(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val myAccount = account ?: return@launch
|
||||
val serverToUse = selectedServer ?: return@launch
|
||||
val orch = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploading = true
|
||||
|
||||
val results =
|
||||
orch.upload(
|
||||
alt = name,
|
||||
contentWarningReason = null,
|
||||
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||
server = serverToUse,
|
||||
account = myAccount,
|
||||
context = context,
|
||||
useH265 = false,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (!results.allGood) {
|
||||
val messages =
|
||||
results.errors
|
||||
.map { stringRes(context, it.errorResource, *it.params) }
|
||||
.distinct()
|
||||
.joinToString(".\n")
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
|
||||
isUploading = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val uploaded =
|
||||
results.successful.firstNotNullOfOrNull {
|
||||
it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
}
|
||||
|
||||
if (uploaded == null) {
|
||||
onError(
|
||||
stringRes(context, R.string.failed_to_upload_media_no_details),
|
||||
"Upload succeeded but no image URL was returned by the server.",
|
||||
)
|
||||
isUploading = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val imageUrl = uploaded.url
|
||||
val dimensions = uploaded.fileHeader.dim
|
||||
|
||||
myAccount.sendBadgeDefinition(
|
||||
badgeId = UUID.randomUUID().toString(),
|
||||
name = name.trim(),
|
||||
imageUrl = imageUrl,
|
||||
imageDim = dimensions,
|
||||
description = description.trim().ifBlank { null },
|
||||
// NIP-58 thumb is optional; we emit it pointing at the same
|
||||
// uploaded asset so clients that only honor the thumb tag also
|
||||
// see the badge image.
|
||||
thumbs = listOf(ThumbTag(imageUrl, dimensions)),
|
||||
)
|
||||
|
||||
myAccount.settings.changeDefaultFileServer(serverToUse)
|
||||
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
|
||||
|
||||
onSuccess()
|
||||
onceUploaded()
|
||||
cancelModel()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelModel() {
|
||||
multiOrchestrator = null
|
||||
isUploading = false
|
||||
name = ""
|
||||
description = ""
|
||||
selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
fun onceUploaded(action: () -> Unit) {
|
||||
this.onceUploaded = action
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ProfileBadgesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
// Pull every kind 8 award tagging me from the user's notification relays so
|
||||
// historic awards land in cache while this screen is open.
|
||||
ProfileBadgesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey))
|
||||
val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey))
|
||||
|
||||
val newState by newNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val oldState by oldNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val acceptedAwardIds =
|
||||
remember(newState, oldState) {
|
||||
val newEvent = newState.note.event as? ProfileBadgesEvent
|
||||
val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent
|
||||
(newEvent?.badgeAwardEvents()?.map { it.eventId } ?: oldEvent?.badgeAwardEvents()?.map { it.eventId } ?: emptyList())
|
||||
.toSet()
|
||||
}
|
||||
|
||||
// Tick whenever LocalCache emits a bundle that contains an award addressed
|
||||
// to me, so the snapshot below recomputes and the new award appears.
|
||||
var bundleTick by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(myPubkey) {
|
||||
launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { bundle ->
|
||||
val touchesMe =
|
||||
bundle.any { note ->
|
||||
val ev = note.event
|
||||
ev is BadgeAwardEvent && ev.awardeeIds().contains(myPubkey)
|
||||
}
|
||||
if (touchesMe) bundleTick++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "loaded" flips once when either profile-badges event arrives in cache, so
|
||||
// we re-sort exactly once after the initial load. Toggling the Switch below
|
||||
// doesn't change it, so the list stays in place while the user edits.
|
||||
val acceptedDataLoaded =
|
||||
newState.note.event != null || oldState.note.event != null
|
||||
|
||||
val receivedAwards =
|
||||
remember(myPubkey, bundleTick, acceptedDataLoaded) {
|
||||
// acceptedAwardIds is captured from the enclosing scope at this
|
||||
// recomputation point; it is NOT part of the remember key, so
|
||||
// subsequent toggles don't re-run this sort.
|
||||
val initialAccepted = acceptedAwardIds
|
||||
LocalCache.notes
|
||||
.filterIntoSet { _, it ->
|
||||
val event = it.event
|
||||
event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey)
|
||||
}.mapNotNull { it.event as? BadgeAwardEvent }
|
||||
.sortedWith(
|
||||
// Accepted badges on top (at load time), then most recent first.
|
||||
compareByDescending<BadgeAwardEvent> { initialAccepted.contains(it.id) }
|
||||
.thenByDescending { it.createdAt },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(stringRes(id = R.string.profile_badges_title), nav::popBack)
|
||||
},
|
||||
) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||
Text(
|
||||
text = stringRes(R.string.profile_badges_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
if (receivedAwards.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.profile_badges_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(20.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(
|
||||
items = receivedAwards,
|
||||
key = { it.id },
|
||||
) { award ->
|
||||
AwardRow(
|
||||
award = award,
|
||||
isAccepted = acceptedAwardIds.contains(award.id),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AwardRow(
|
||||
award: BadgeAwardEvent,
|
||||
isAccepted: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val defAddr = award.awardDefinition().firstOrNull()
|
||||
|
||||
if (defAddr == null) {
|
||||
StaticAwardRow(
|
||||
definition = null,
|
||||
defNote = null,
|
||||
isAccepted = isAccepted,
|
||||
accountViewModel = accountViewModel,
|
||||
award = award,
|
||||
nav = nav,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
|
||||
if (defNote == null) {
|
||||
StaticAwardRow(
|
||||
definition = null,
|
||||
defNote = null,
|
||||
isAccepted = isAccepted,
|
||||
accountViewModel = accountViewModel,
|
||||
award = award,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
// Ask relays for the definition if we don't have it yet, then watch.
|
||||
EventFinderFilterAssemblerSubscription(defNote, accountViewModel)
|
||||
val definition by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
|
||||
StaticAwardRow(
|
||||
definition = definition,
|
||||
defNote = defNote,
|
||||
isAccepted = isAccepted,
|
||||
accountViewModel = accountViewModel,
|
||||
award = award,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaticAwardRow(
|
||||
definition: BadgeDefinitionEvent?,
|
||||
defNote: com.vitorpamplona.amethyst.model.AddressableNote?,
|
||||
isAccepted: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
award: BadgeAwardEvent,
|
||||
nav: INav,
|
||||
) {
|
||||
val rowModifier =
|
||||
if (defNote != null) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
routeFor(defNote, accountViewModel.account)?.let { nav.nav(it) }
|
||||
}.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = rowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BadgeThumb(definition)
|
||||
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = definition?.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
definition?.description()?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
|
||||
Switch(
|
||||
checked = isAccepted,
|
||||
enabled = definition != null,
|
||||
onCheckedChange = { checked ->
|
||||
accountViewModel.launchSigner {
|
||||
if (checked) {
|
||||
val defEvent = definition ?: return@launchSigner
|
||||
accountViewModel.account.addAcceptedBadge(award, defEvent)
|
||||
} else {
|
||||
accountViewModel.account.removeAcceptedBadge(award)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeThumb(definition: BadgeDefinitionEvent?) {
|
||||
val imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image()?.ifBlank { null }
|
||||
val thumbModifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp))
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
RobohashAsyncImage(
|
||||
robot = definition?.id ?: "badgenotfound",
|
||||
contentDescription = null,
|
||||
modifier = thumbModifier,
|
||||
loadRobohash = true,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = thumbModifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
|
||||
/**
|
||||
* Pulls every badge award (kind 8) tagging this pubkey, from the user's
|
||||
* notification relays. Used by the "Profile badges" management screen to
|
||||
* back-fill the full history of awards (the always-on notifications
|
||||
* subscription is bounded by `since`, so older awards may not be in
|
||||
* cache yet).
|
||||
*/
|
||||
fun filterReceivedBadgeAwards(
|
||||
pubkey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isEmpty() || relays.isEmpty()) return emptyList()
|
||||
val pTags = listOf(pubkey)
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(BadgeAwardEvent.KIND),
|
||||
tags = mapOf("p" to pTags),
|
||||
limit = 500,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
class ProfileBadgesQueryState(
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class ProfileBadgesFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<ProfileBadgesQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
ProfileBadgesSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ProfileBadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
ProfileBadgesQueryState(accountViewModel.account)
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, accountViewModel.dataSources().profileBadges)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
class ProfileBadgesSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ProfileBadgesQueryState>,
|
||||
) : PerUserEoseManager<ProfileBadgesQueryState>(client, allKeys) {
|
||||
override fun user(key: ProfileBadgesQueryState) = key.account.userProfile()
|
||||
|
||||
override fun updateFilter(
|
||||
key: ProfileBadgesQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
filterReceivedBadgeAwards(
|
||||
pubkey = user(key).pubkeyHex,
|
||||
relays = key.account.notificationRelays.flow.value,
|
||||
)
|
||||
}
|
||||
+2
@@ -79,6 +79,7 @@ fun RenderEncryptedFile(
|
||||
encryptionAlgo = algo,
|
||||
encryptionKey = key,
|
||||
encryptionNonce = nonce,
|
||||
thumbhash = noteEvent.thumbhash(),
|
||||
)
|
||||
} else {
|
||||
EncryptedMediaUrlVideo(
|
||||
@@ -93,6 +94,7 @@ fun RenderEncryptedFile(
|
||||
encryptionAlgo = algo,
|
||||
encryptionKey = key,
|
||||
encryptionNonce = nonce,
|
||||
thumbhash = noteEvent.thumbhash(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
+2
@@ -139,6 +139,7 @@ private fun RenderMip04Content(
|
||||
encryptionAlgo = meta.version,
|
||||
encryptionKey = exporterSecret,
|
||||
encryptionNonce = meta.nonceBytes,
|
||||
thumbhash = meta.thumbhash,
|
||||
)
|
||||
} else {
|
||||
EncryptedMediaUrlVideo(
|
||||
@@ -153,6 +154,7 @@ private fun RenderMip04Content(
|
||||
encryptionAlgo = meta.version,
|
||||
encryptionKey = exporterSecret,
|
||||
encryptionNonce = meta.nonceBytes,
|
||||
thumbhash = meta.thumbhash,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ class MarmotFileSender(
|
||||
nonce = upload.nonce,
|
||||
dimensions = upload.dimensions,
|
||||
blurhash = upload.blurhash,
|
||||
thumbhash = upload.thumbhash,
|
||||
)
|
||||
|
||||
accountViewModel.sendMarmotGroupMediaMessage(
|
||||
|
||||
+2
@@ -43,6 +43,7 @@ class Mip04UploadResult(
|
||||
val dimensions: String?,
|
||||
val blurhash: String?,
|
||||
val caption: String?,
|
||||
val thumbhash: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -102,6 +103,7 @@ class MarmotFileUploader(
|
||||
dimensions = serverResult.fileHeader.dim?.toString(),
|
||||
blurhash = serverResult.fileHeader.blurHash?.blurhash,
|
||||
caption = viewState.caption.ifEmpty { null },
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
|
||||
+3
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.Locale
|
||||
|
||||
@@ -60,6 +61,7 @@ class IMetaAttachments {
|
||||
it.mimeType?.let { mimeType(it) }
|
||||
it.dim?.let { dims(it) }
|
||||
it.blurHash?.let { blurhash(it.blurhash) }
|
||||
it.thumbHash?.let { thumbhash(it.thumbhash) }
|
||||
}.build()
|
||||
}
|
||||
|
||||
@@ -101,6 +103,7 @@ class IMetaAttachments {
|
||||
result.fileHeader.mimeType?.let { mimeType(it) }
|
||||
result.fileHeader.dim?.let { dims(it) }
|
||||
result.fileHeader.blurHash?.let { blurhash(it.blurhash) }
|
||||
result.fileHeader.thumbHash?.let { thumbhash(it.thumbhash) }
|
||||
result.magnet?.let { magnet(it) }
|
||||
result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
|
||||
+12
@@ -626,6 +626,9 @@ fun ShowImageUploadGallery(
|
||||
encryptionAlgo = data.cipher.name(),
|
||||
encryptionKey = data.cipher.keyBytes,
|
||||
encryptionNonce = data.cipher.nonce,
|
||||
thumbhash =
|
||||
data.result.fileHeader.thumbHash
|
||||
?.thumbhash,
|
||||
)
|
||||
} else {
|
||||
EncryptedMediaUrlVideo(
|
||||
@@ -641,6 +644,9 @@ fun ShowImageUploadGallery(
|
||||
encryptionAlgo = data.cipher.name(),
|
||||
encryptionKey = data.cipher.keyBytes,
|
||||
encryptionNonce = data.cipher.nonce,
|
||||
thumbhash =
|
||||
data.result.fileHeader.thumbHash
|
||||
?.thumbhash,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -655,6 +661,9 @@ fun ShowImageUploadGallery(
|
||||
dim = data.result.fileHeader.dim,
|
||||
uri = null,
|
||||
mimeType = data.result.mimeTypeBeforeEncryption,
|
||||
thumbhash =
|
||||
data.result.fileHeader.thumbHash
|
||||
?.thumbhash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -667,6 +676,9 @@ fun ShowImageUploadGallery(
|
||||
dim = data.result.fileHeader.dim,
|
||||
uri = null,
|
||||
mimeType = data.result.mimeTypeBeforeEncryption,
|
||||
thumbhash =
|
||||
data.result.fileHeader.thumbHash
|
||||
?.thumbhash,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+1
@@ -64,6 +64,7 @@ class ChatFileSender(
|
||||
size = result.fileHeader.size,
|
||||
dimension = result.fileHeader.dim,
|
||||
blurhash = result.fileHeader.blurHash?.blurhash,
|
||||
thumbhash = result.fileHeader.thumbHash?.thumbhash,
|
||||
) {
|
||||
if (!caption.isNullOrEmpty()) {
|
||||
alt(caption)
|
||||
|
||||
+3
@@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -544,6 +545,8 @@ class LongFormPostViewModel :
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
|
||||
+25
-35
@@ -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,23 +153,17 @@ 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,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
card.personalized?.let {
|
||||
var color = Color.DarkGray
|
||||
@@ -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,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 4.dp)
|
||||
.weight(1f, fill = false)
|
||||
.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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+10
-6
@@ -429,14 +429,18 @@ open class NewProductViewModel :
|
||||
) {
|
||||
productImages = productImages +
|
||||
ProductImageMeta(
|
||||
it.result.url,
|
||||
it.result.fileHeader.mimeType,
|
||||
url = it.result.url,
|
||||
mimeType = it.result.fileHeader.mimeType,
|
||||
blurhash =
|
||||
it.result.fileHeader.blurHash
|
||||
?.blurhash,
|
||||
it.result.fileHeader.dim,
|
||||
alt,
|
||||
it.result.fileHeader.hash,
|
||||
it.result.fileHeader.size,
|
||||
dimension = it.result.fileHeader.dim,
|
||||
alt = alt,
|
||||
hash = it.result.fileHeader.hash,
|
||||
size = it.result.fileHeader.size,
|
||||
thumbhash =
|
||||
it.result.fileHeader.thumbHash
|
||||
?.thumbhash,
|
||||
)
|
||||
} else {
|
||||
iMetaDescription.add(it.result, alt, contentWarningReason)
|
||||
|
||||
+26
-13
@@ -354,10 +354,28 @@ fun FeedDVM(
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(currentStatus, textAlign = TextAlign.Center)
|
||||
|
||||
if (status.code == "payment-required") {
|
||||
DvmPaymentActions(
|
||||
latestStatus = latestStatus,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onStatusUpdate = { currentStatus = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DvmPaymentActions(
|
||||
latestStatus: NIP90StatusEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onStatusUpdate: (String) -> Unit,
|
||||
) {
|
||||
val status = latestStatus.status() ?: return
|
||||
|
||||
if (status.code != "payment-required") return
|
||||
|
||||
val amountTag = latestStatus.firstAmount()
|
||||
val amount = amountTag?.amount
|
||||
|
||||
val invoice = amountTag?.lnInvoice
|
||||
|
||||
val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment)
|
||||
@@ -371,10 +389,10 @@ fun FeedDVM(
|
||||
bolt11 = invoice,
|
||||
zappedNote = null,
|
||||
onSent = {
|
||||
currentStatus = nwcPaymentRequest
|
||||
onStatusUpdate(nwcPaymentRequest)
|
||||
},
|
||||
onResponse = { response ->
|
||||
currentStatus =
|
||||
onStatusUpdate(
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
stringRes(
|
||||
context,
|
||||
@@ -384,7 +402,8 @@ fun FeedDVM(
|
||||
)
|
||||
} else {
|
||||
thankYou
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -392,10 +411,10 @@ fun FeedDVM(
|
||||
invoice,
|
||||
context,
|
||||
onPaid = {
|
||||
currentStatus = thankYou
|
||||
onStatusUpdate(thankYou)
|
||||
},
|
||||
onError = {
|
||||
currentStatus = it
|
||||
onStatusUpdate(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -426,12 +445,6 @@ fun FeedDVM(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (status.code == "processing") {
|
||||
currentStatus = status.description
|
||||
} else if (status.code == "error") {
|
||||
currentStatus = status.description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+30
-3
@@ -21,19 +21,25 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun DvmTopBar(
|
||||
@@ -41,7 +47,7 @@ fun DvmTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
TopBarExtensibleWithBackButton(
|
||||
MyExtensibleTopAppBar(
|
||||
title = {
|
||||
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
||||
if (appDefinitionNote != null) {
|
||||
@@ -82,6 +88,27 @@ fun DvmTopBar(
|
||||
}
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } },
|
||||
actions = {
|
||||
// The route passes the event's hex id, so LoadNote returns a plain Note,
|
||||
// not the AddressableNote the toggle needs. Derive the AddressableNote
|
||||
// from the loaded AppDefinitionEvent's address() once the event exists.
|
||||
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
||||
if (appDefinitionNote != null) {
|
||||
val addressableNote by
|
||||
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)?.let {
|
||||
LocalCache.getOrCreateAddressableNote(it.address())
|
||||
}
|
||||
}
|
||||
addressableNote?.let { target ->
|
||||
FavoriteAlgoFeedToggle(
|
||||
appDefinitionNote = target,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.StarBorder
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
|
||||
/**
|
||||
* Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM.
|
||||
*
|
||||
* Uses [ClickableBox] (no [androidx.compose.material3.IconButton] padding) so it
|
||||
* fits in a `LeftPictureLayout` title row alongside other compact reactions
|
||||
* without inflating the row to 48dp.
|
||||
*
|
||||
* Hidden until the underlying [AppDefinitionEvent] loads and we can confirm the
|
||||
* DVM advertises kind 5300 — favouriting any other DVM type would only stall on
|
||||
* a 6300 reply that never comes.
|
||||
*/
|
||||
@Composable
|
||||
fun FavoriteAlgoFeedToggle(
|
||||
appDefinitionNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
iconSizeModifier: Modifier = Size20Modifier,
|
||||
) {
|
||||
val supportsContentDiscovery by
|
||||
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true
|
||||
}
|
||||
|
||||
if (!supportsContentDiscovery) return
|
||||
|
||||
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val isFavorite = favorites.contains(appDefinitionNote.address)
|
||||
|
||||
ClickableBox(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isFavorite) {
|
||||
accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address)
|
||||
} else {
|
||||
accountViewModel.followFavoriteAlgoFeed(
|
||||
AddressBookmark(
|
||||
address = appDefinitionNote.address,
|
||||
relayHint = appDefinitionNote.relayHintUrl(),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (isFavorite) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
|
||||
modifier = iconSizeModifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.StarBorder,
|
||||
contentDescription = stringRes(R.string.add_dvm_to_favorites),
|
||||
modifier = iconSizeModifier,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.BottomStart
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FavoriteAlgoFeedsListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(
|
||||
caption = stringRes(R.string.favorite_dvms_title),
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
top = padding.calculateTopPadding(),
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_explainer),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
FavoriteAlgoFeedList(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteAlgoFeedList(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_empty),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
items(
|
||||
items = favorites,
|
||||
key = { it.address.toValue() },
|
||||
) { feedNote ->
|
||||
FavoriteAlgoFeedRow(
|
||||
feedNote = feedNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) },
|
||||
onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FavoriteAlgoFeedRow(
|
||||
feedNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val card = observeAppDefinition(feedNote, accountViewModel)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onOpen)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
card.cover?.let { cover ->
|
||||
Box(contentAlignment = BottomStart) {
|
||||
MyAsyncImage(
|
||||
imageUrl = cover,
|
||||
contentDescription = card.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
mainImageModifier = Modifier,
|
||||
loadedImageModifier = SimpleImage35Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onLoadingBackground = {
|
||||
feedNote.author?.let { author ->
|
||||
BannerImage(author, SimpleImage35Modifier, accountViewModel)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
feedNote.author?.let { author ->
|
||||
BannerImage(author, SimpleImage35Modifier, accountViewModel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
feedNote.author?.let { author ->
|
||||
BannerImage(author, SimpleImage35Modifier, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = card.name.ifBlank { feedNote.dTag() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
card.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun HomeAlgoFeedStatusBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val topFilter by accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
when (val filter = topFilter) {
|
||||
is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier)
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleAlgoFeedBanner(
|
||||
favFeed: TopFilter.FavoriteAlgoFeed,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator
|
||||
.observe(favFeed.address)
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Hide the banner when the feed is already populated.
|
||||
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
|
||||
|
||||
val feedAddressValue = favFeed.address.toValue()
|
||||
|
||||
LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote ->
|
||||
val resolvedName by
|
||||
observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)
|
||||
?.appMetaData()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag()
|
||||
?: ""
|
||||
}
|
||||
|
||||
BannerCard(modifier) {
|
||||
val status = snapshot.latestStatus?.status()
|
||||
|
||||
when {
|
||||
snapshot.errorMessage != null -> {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_error),
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
|
||||
}
|
||||
|
||||
status?.code == "payment-required" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_payment_required)
|
||||
},
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
var statusOverride by remember { mutableStateOf<String?>(null) }
|
||||
val msg = statusOverride
|
||||
if (msg != null) {
|
||||
Text(text = msg, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
snapshot.latestStatus?.let {
|
||||
DvmPaymentActions(
|
||||
latestStatus = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onStatusUpdate = { statusOverride = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
status?.code == "error" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_error)
|
||||
},
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
|
||||
}
|
||||
|
||||
status?.code == "processing" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_processing)
|
||||
},
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_requesting, resolvedName),
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllFavoriteAlgoFeedsBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (addresses.isEmpty()) return
|
||||
|
||||
// Observe each DVM's snapshot so we can decide whether to hide the banner
|
||||
// based on the aggregate state. Hide it as soon as any DVM has produced a
|
||||
// feed; only error out when every one of them has errored.
|
||||
val snapshots: List<FavoriteAlgoFeedsSnapshot> =
|
||||
addresses.map { address ->
|
||||
val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator
|
||||
.observe(address)
|
||||
.collectAsStateWithLifecycle()
|
||||
snap
|
||||
}
|
||||
|
||||
val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() }
|
||||
if (anyResponded) return
|
||||
|
||||
val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" }
|
||||
|
||||
BannerCard(modifier) {
|
||||
if (allErrored) {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_error),
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton {
|
||||
addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
|
||||
}
|
||||
} else {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_requesting_all),
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Sits above the feed instead of in the topBar Column, so adding/removing
|
||||
// the banner doesn't shift the tabs/filter up and down. tonalElevation
|
||||
// gives it a faint surface tint so it reads as a floating overlay.
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerMessageRow(
|
||||
message: String,
|
||||
showSpinner: Boolean,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
if (showSpinner) {
|
||||
LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RetryButton(onClick: () -> Unit) {
|
||||
OutlinedButton(onClick = onClick) {
|
||||
Text(stringRes(R.string.dvm_home_retry))
|
||||
}
|
||||
}
|
||||
+52
-3
@@ -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,9 +213,15 @@ private fun HomePages(
|
||||
HomeScreenFloatingButton(accountViewModel, nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { 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 = it,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
modifier =
|
||||
@@ -229,6 +239,13 @@ private fun HomePages(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
HomeAlgoFeedStatusBanner(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +298,23 @@ fun HomeFeeds(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RefresheableBox(feedState, enablePullRefresh) {
|
||||
val activeFilter by accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val onRefresh: () -> Unit = {
|
||||
feedState.invalidateData()
|
||||
// Swiping down on Home should also re-issue the kind-5300 request(s) so the
|
||||
// DVM(s) produce fresh feeds, not just re-render whatever's cached.
|
||||
when (val filter = activeFilter) {
|
||||
is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address)
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
RefresheableHomeBox(onRefresh, enablePullRefresh) {
|
||||
SaveableFeedContentState(feedState, scrollStateKey) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedState,
|
||||
@@ -290,12 +323,28 @@ fun HomeFeeds(
|
||||
nav = nav,
|
||||
routeForLastRead = routeForLastRead,
|
||||
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
|
||||
onEmpty = { HomeFeedEmpty(feedState::invalidateData) },
|
||||
onEmpty = { HomeFeedEmpty(onRefresh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefresheableHomeBox(
|
||||
onRefresh: () -> Unit,
|
||||
enablePullRefresh: Boolean,
|
||||
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
|
||||
) {
|
||||
if (enablePullRefresh) {
|
||||
RefresheableBox(onRefresh = onRefresh, content = content)
|
||||
} else {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxSize(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FeedLoaded(
|
||||
|
||||
+3
@@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
@@ -1124,6 +1125,8 @@ open class ShortNotePostViewModel :
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
@@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
@@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager(
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
|
||||
/**
|
||||
* Builds relay REQ filters for a favorite-DVM home feed.
|
||||
*
|
||||
* Two distinct subscription kinds with two distinct relay sets:
|
||||
*
|
||||
* - **Content fetch** — for each of the user's outbox/proxy relays, request the
|
||||
* note IDs and addressable references the DVM curated. Notes typically live on
|
||||
* the user's normal relays, so this is where we fetch them.
|
||||
*
|
||||
* - **Response listen** — for each relay the DVM advertised (where it received
|
||||
* the kind-5300 request and will publish its 6300/7000 reply), subscribe to
|
||||
* future kind 6300 / 7000 events tagged with the request id. The DVM almost
|
||||
* never publishes responses on the user's outbox, so listening anywhere else
|
||||
* would silently miss them.
|
||||
*/
|
||||
fun filterHomePostsByAlgoFeedIds(
|
||||
set: FavoriteAlgoFeedTopNavPerRelayFilterSet,
|
||||
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
|
||||
@Suppress("UNUSED_PARAMETER") defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
val out = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
set.contentFetches.forEach { (relay, filter) ->
|
||||
out += contentFetchFilters(relay, filter)
|
||||
}
|
||||
|
||||
if (set.requestIds.isNotEmpty()) {
|
||||
val requestIds = set.requestIds.toList()
|
||||
set.listenRelays.forEach { relay ->
|
||||
out += responseListenFilter(relay, requestIds)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun contentFetchFilters(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: FavoriteAlgoFeedTopNavPerRelayFilter,
|
||||
): List<RelayBasedFilter> {
|
||||
val out = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
if (filter.ids.isNotEmpty()) {
|
||||
out +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
ids = filter.ids.toList(),
|
||||
limit = filter.ids.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (filter.addresses.isNotEmpty()) {
|
||||
out +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
tags = mapOf("a" to filter.addresses.toList()),
|
||||
limit = filter.addresses.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun responseListenFilter(
|
||||
relay: NormalizedRelayUrl,
|
||||
requestIds: List<HexKey>,
|
||||
) = RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds =
|
||||
listOf(
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to requestIds),
|
||||
limit = 10 * requestIds.size,
|
||||
),
|
||||
)
|
||||
+3
@@ -107,6 +107,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
@@ -486,6 +487,8 @@ class NewPublicMessageViewModel :
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
|
||||
+2
-1
@@ -115,6 +115,7 @@ private fun PictureCardImage(
|
||||
dim = it.dimension,
|
||||
uri = uri,
|
||||
mimeType = it.mimeType,
|
||||
thumbhash = it.thumbhash,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
@@ -131,7 +132,7 @@ private fun PictureCardImage(
|
||||
preloadUrls = listOf(single.url),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
|
||||
backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } },
|
||||
backdrop = (single.thumbhash ?: single.blurhash)?.let { { BlurhashBackdrop(single.blurhash, single.description, single.thumbhash) } },
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = single,
|
||||
|
||||
+8
-2
@@ -86,6 +86,7 @@ fun GalleryThumbnail(
|
||||
dim = noteEvent.dimensions(),
|
||||
uri = null,
|
||||
mimeType = noteEvent.mimeType(),
|
||||
thumbhash = noteEvent.thumbhash(),
|
||||
)
|
||||
} else {
|
||||
MediaUrlImage(
|
||||
@@ -97,6 +98,7 @@ fun GalleryThumbnail(
|
||||
dim = noteEvent.dimensions(),
|
||||
uri = null,
|
||||
mimeType = noteEvent.mimeType(),
|
||||
thumbhash = noteEvent.thumbhash(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +113,7 @@ fun GalleryThumbnail(
|
||||
dim = imeta.dimension,
|
||||
uri = null,
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
}
|
||||
} else if (noteEvent is VideoEvent) {
|
||||
@@ -124,6 +127,7 @@ fun GalleryThumbnail(
|
||||
dim = imeta.dimension,
|
||||
uri = null,
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -210,12 +214,13 @@ fun UrlImageView(
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading,
|
||||
-> {
|
||||
if (content.blurhash != null) {
|
||||
if (content.blurhash != null || content.thumbhash != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
defaultModifier,
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
} else {
|
||||
Box(defaultModifier, contentAlignment = Alignment.Center) {
|
||||
@@ -243,12 +248,13 @@ fun UrlImageView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
if (content.blurhash != null || content.thumbhash != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
defaultModifier.clickable { showImage.value = true },
|
||||
thumbhash = content.thumbhash,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayCircleOutline,
|
||||
|
||||
+231
-45
@@ -20,17 +20,44 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -43,12 +70,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -60,8 +86,12 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val ProfileBadgeSize = 44.dp
|
||||
private val ProfileBadgeShape = RoundedCornerShape(8.dp)
|
||||
private const val VISIBLE_BADGE_LIMIT = 8
|
||||
|
||||
@Composable
|
||||
fun DisplayBadges(
|
||||
@@ -75,91 +105,242 @@ fun DisplayBadges(
|
||||
val oldNote = accountViewModel.getOrCreateAddressableNote(oldDesign)
|
||||
val newNote = accountViewModel.getOrCreateAddressableNote(newDesign)
|
||||
|
||||
WatchAndRenderBadgeList(oldNote, newNote, accountViewModel, nav)
|
||||
WatchAndRenderBadgeList(baseUser, oldNote, newNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAndRenderBadgeList(
|
||||
baseUser: User,
|
||||
oldNote: AddressableNote,
|
||||
newNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Subscribe in the relay for changes in this note.
|
||||
EventFinderFilterAssemblerSubscription(oldNote, accountViewModel)
|
||||
EventFinderFilterAssemblerSubscription(newNote, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow =
|
||||
remember(oldNote, newNote) {
|
||||
combine(
|
||||
oldNote.flow().metadata.stateFlow,
|
||||
newNote.flow().metadata.stateFlow,
|
||||
) { oldNote, newNote ->
|
||||
val oldProfileBadgeEvent = oldNote.note.event as? AcceptedBadgeSetEvent
|
||||
val newProfileBadgeEvent = newNote.note.event as? ProfileBadgesEvent
|
||||
) { oldNoteState, newNoteState ->
|
||||
val oldEvent = oldNoteState.note.event as? AcceptedBadgeSetEvent
|
||||
val newEvent = newNoteState.note.event as? ProfileBadgesEvent
|
||||
|
||||
newProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
?: oldProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
newEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
?: oldEvent?.badgeAwardEvents()?.toImmutableList()
|
||||
?: persistentListOf()
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val badgeList by flow.collectAsStateWithLifecycle(persistentListOf())
|
||||
|
||||
badgeList?.let { list -> RenderBadgeList(list, accountViewModel, nav) }
|
||||
if (badgeList.isEmpty()) return
|
||||
|
||||
val isMe = baseUser.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||
RenderProfileBadgeStrip(badgeList, isMe, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun RenderBadgeList(
|
||||
@Composable
|
||||
private fun RenderProfileBadgeStrip(
|
||||
list: ImmutableList<ETag>,
|
||||
isMe: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
var showAllSheet by rememberSaveable { mutableStateOf(false) }
|
||||
val visible = remember(list) { list.take(VISIBLE_BADGE_LIMIT) }
|
||||
val overflow = (list.size - VISIBLE_BADGE_LIMIT).coerceAtLeast(0)
|
||||
|
||||
Column(modifier = Modifier.padding(vertical = 6.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) }
|
||||
Text(
|
||||
text = stringRes(R.string.profile_badges_header, list.size),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isMe) {
|
||||
IconButton(
|
||||
onClick = { nav.nav(Route.ProfileBadges) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = stringRes(R.string.profile_badges_title),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
visible.forEach { eTag ->
|
||||
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
|
||||
BadgeThumb(defNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
if (overflow > 0) {
|
||||
OverflowChip(overflow) { showAllSheet = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAllSheet) {
|
||||
AllBadgesSheet(
|
||||
awards = list,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onDismiss = { showAllSheet = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadAndRenderBadge(
|
||||
badgeAwardEvent: ETag,
|
||||
private fun OverflowChip(
|
||||
count: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ProfileBadgeSize)
|
||||
.clip(ProfileBadgeShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+$count",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AllBadgesSheet(
|
||||
awards: ImmutableList<ETag>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val baseNote =
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.profile_badges_header, awards.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(items = awards, key = { it.eventId }) { eTag ->
|
||||
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
|
||||
BadgeSheetRow(
|
||||
defNote = defNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
val route = routeFor(defNote, accountViewModel.account)
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onDismiss()
|
||||
route?.let { nav.nav(it) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeSheetRow(
|
||||
defNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val event by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
|
||||
val definition = event ?: return
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RenderBadgeImage(
|
||||
id = definition.id,
|
||||
name = definition.name(),
|
||||
image =
|
||||
definition.thumb()?.ifBlank { null }
|
||||
?: definition.image()?.ifBlank { null },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = definition.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
definition.description()?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadDefinitionForAward(
|
||||
eTag: ETag,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (Note) -> Unit,
|
||||
) {
|
||||
val awardNote =
|
||||
produceState(
|
||||
LocalCache.getNoteIfExists(badgeAwardEvent),
|
||||
badgeAwardEvent,
|
||||
LocalCache.getNoteIfExists(eTag),
|
||||
eTag,
|
||||
) {
|
||||
val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent)
|
||||
val newValue = LocalCache.checkGetOrCreateNote(eTag)
|
||||
if (newValue != value) {
|
||||
value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.value?.let {
|
||||
ObserveAndRenderBadge(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveAndRenderBadge(
|
||||
it: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val badgeAwardState by observeNoteEvent<BadgeAwardEvent>(it, accountViewModel)
|
||||
val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull()
|
||||
if (badgeDefinitionId != null) {
|
||||
LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote ->
|
||||
badgeDefNote?.let {
|
||||
BadgeThumb(it, accountViewModel, nav)
|
||||
awardNote.value?.let { note ->
|
||||
val awardEvent by observeNoteEvent<BadgeAwardEvent>(note, accountViewModel)
|
||||
awardEvent?.awardDefinition()?.firstOrNull()?.let { defAddr ->
|
||||
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
|
||||
defNote?.let { content(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +354,10 @@ fun BadgeThumb(
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Size35Modifier.clickable(
|
||||
Modifier
|
||||
.size(ProfileBadgeSize)
|
||||
.clip(ProfileBadgeShape)
|
||||
.clickable(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeFor(baseNote, accountViewModel.account)
|
||||
@@ -215,11 +399,13 @@ private fun RenderBadgeImage(
|
||||
stringRes(id = R.string.badge_award_image)
|
||||
}
|
||||
|
||||
val modifier = Modifier.size(ProfileBadgeSize).clip(ProfileBadgeShape)
|
||||
|
||||
if (image == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = "badgenotfound",
|
||||
contentDescription = description,
|
||||
modifier = BadgePictureModifier,
|
||||
modifier = modifier,
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
} else {
|
||||
@@ -227,7 +413,7 @@ private fun RenderBadgeImage(
|
||||
robot = id,
|
||||
model = image,
|
||||
contentDescription = description,
|
||||
modifier = BadgePictureModifier,
|
||||
modifier = modifier,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
|
||||
+16
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.CloudUpload
|
||||
import androidx.compose.material.icons.outlined.DeleteForever
|
||||
@@ -36,6 +37,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.outlined.MilitaryTech
|
||||
import androidx.compose.material.icons.outlined.Phone
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Security
|
||||
@@ -122,6 +124,20 @@ fun AllSettingsScreen(
|
||||
onClick = { nav.nav(Route.EditMediaServers) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.profile_badges_title,
|
||||
icon = Icons.Outlined.MilitaryTech,
|
||||
tint = tint,
|
||||
onClick = { nav.nav(Route.ProfileBadges) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.favorite_dvms_title,
|
||||
icon = Icons.Outlined.AutoAwesome,
|
||||
tint = tint,
|
||||
onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.reactions,
|
||||
icon = Icons.Outlined.FavoriteBorder,
|
||||
|
||||
+3
-1
@@ -121,6 +121,7 @@ private fun VideoCardImage(
|
||||
dim = imeta.dimension,
|
||||
uri = note.toNostrUri(),
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -132,6 +133,7 @@ private fun VideoCardImage(
|
||||
uri = note.toNostrUri(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = imeta.mimeType,
|
||||
thumbhash = imeta.thumbhash,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -145,7 +147,7 @@ private fun VideoCardImage(
|
||||
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
|
||||
backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
|
||||
backdrop = (imeta.thumbhash ?: imeta.blurhash)?.let { { BlurhashBackdrop(imeta.blurhash, content.description, imeta.thumbhash) } },
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
|
||||
+1
@@ -922,6 +922,7 @@ private fun RenderClassifiedsReaderForThread(
|
||||
dim = it.dimension,
|
||||
uri = note.toNostrUri(),
|
||||
mimeType = it.mimeType,
|
||||
thumbhash = it.thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -104,6 +104,7 @@ private fun FileHeaderCardImage(
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
|
||||
val blurHash = remember(note) { event.blurhash() }
|
||||
val thumbHash = remember(note) { event.thumbhash() }
|
||||
val dimensions = remember(note) { event.dimensions() }
|
||||
|
||||
val content by remember(note) {
|
||||
@@ -122,6 +123,7 @@ private fun FileHeaderCardImage(
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
@@ -133,6 +135,7 @@ private fun FileHeaderCardImage(
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -146,7 +149,7 @@ private fun FileHeaderCardImage(
|
||||
preloadUrls = if (isImage) listOf(fullUrl) else emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
|
||||
backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
|
||||
backdrop = (thumbHash ?: blurHash)?.let { { BlurhashBackdrop(blurHash, content.description, thumbHash) } },
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní
|
||||
a proto zprávy časem mizí</string>
|
||||
<string name="public_chat">Veřejný chat</string>
|
||||
<string name="marmot_group">MLS skupina</string>
|
||||
<string name="public_chat_title">Metadata veřejného chatu</string>
|
||||
<string name="public_chat_explainer">Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli
|
||||
se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat.
|
||||
@@ -389,6 +390,7 @@
|
||||
<string name="pictures">Obrázky</string>
|
||||
<string name="shorts">Krátká videa</string>
|
||||
<string name="longs">Videa</string>
|
||||
<string name="articles">Články</string>
|
||||
<string name="private_bookmarks">Soukromé záložky</string>
|
||||
<string name="public_bookmarks">Veřejné záložky</string>
|
||||
<string name="add_to_private_bookmarks">Přidat do soukromých záložek</string>
|
||||
@@ -396,8 +398,12 @@
|
||||
<string name="remove_from_private_bookmarks">Odebrat ze soukromých záložek</string>
|
||||
<string name="remove_from_public_bookmarks">Odebrat z veřejných záložek</string>
|
||||
<string name="pinned_notes">Připnuté poznámky</string>
|
||||
<string name="pinned_notes_explainer">Vaše připnuté poznámky</string>
|
||||
<string name="pin_to_profile">Připnout na profil</string>
|
||||
<string name="unpin_from_profile">Odepnout z profilu</string>
|
||||
<string name="deleted_items_banner_title">%1$d položek v tomto seznamu bylo smazáno autorem.</string>
|
||||
<string name="deleted_items_banner_remove">Odebrat ze seznamu</string>
|
||||
<string name="deleted_items_banner_dismiss">Zavřít</string>
|
||||
<string name="bookmark_lists">Seznamy záložek</string>
|
||||
<string name="bookmark_list_icon_label">Ikona seznamu záložek</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nový seznam záložek</string>
|
||||
@@ -712,6 +718,8 @@
|
||||
<string name="call_failed_accept">Nepodařilo se přijmout hovor</string>
|
||||
<string name="call_failed_session">Nepodařilo se vytvořit relaci hovoru</string>
|
||||
<string name="call_settings">Nastavení hovorů</string>
|
||||
<string name="call_settings_enable_calls">Povolit hlasové a video hovory</string>
|
||||
<string name="call_settings_enable_calls_description">Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány.</string>
|
||||
<string name="call_settings_video_quality">Kvalita videa</string>
|
||||
<string name="call_settings_max_bitrate">Maximální datový tok videa</string>
|
||||
<string name="call_settings_turn_servers">TURN / STUN servery</string>
|
||||
@@ -937,6 +945,7 @@
|
||||
<string name="geohash_exclusive_explainer">Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exkluzivní příspěvek</string>
|
||||
<string name="hashtag_exclusive_explainer">Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí.</string>
|
||||
<string name="long_form_reading_minutes">%1$d min čtení</string>
|
||||
<string name="loading_location">Načítání umístění</string>
|
||||
<string name="lack_location_permissions">Žádná lokace oprávnění</string>
|
||||
<string name="add_sensitive_content_explainer">Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující</string>
|
||||
@@ -945,6 +954,7 @@
|
||||
<string name="new_feature_nip17_might_not_be_available_description">Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-17 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-17 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta.</string>
|
||||
<string name="new_feature_nip17_activate">Aktivovat</string>
|
||||
<string name="messages_create_public_chat">Veřejné</string>
|
||||
<string name="messages_create_group">Skupina</string>
|
||||
<string name="messages_create_public_private_chat_description">Nová veřejná nebo soukromá skupina</string>
|
||||
<string name="messages_relay_based">Relé</string>
|
||||
<string name="messages_new_message">Soukromé</string>
|
||||
@@ -1338,6 +1348,8 @@
|
||||
<string name="private_inbox_section">DM schránka relé</string>
|
||||
<string name="private_inbox_section_explainer_profile">Uživatel přijímá soukromé zprávy na těchto přenašečích</string>
|
||||
<string name="private_inbox_section_explainer">Vložte mezi 1–3 relé, která budou sloužit jako vaše soukromá schránka. Ostatní použijí tato relé k posílání DM zpráv vám. DM schránka relé by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené)</string>
|
||||
<string name="keypackage_section">KeyPackage relays</string>
|
||||
<string name="keypackage_section_explainer">Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení.</string>
|
||||
<string name="private_outbox_section">Soukromá relé</string>
|
||||
<string name="private_outbox_section_explainer">Vložte mezi 1–3 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele.</string>
|
||||
<string name="kind_3_section">Obecná relé</string>
|
||||
@@ -1475,7 +1487,20 @@
|
||||
<string name="feed_group_locations">Lokace</string>
|
||||
<string name="feed_group_communities">Komunity</string>
|
||||
<string name="feed_group_lists">Seznamy</string>
|
||||
<string name="feed_group_dvms">Algoritmy zdrojů</string>
|
||||
<string name="follow_list_all_favorite_dvms">Všechny oblíbené algoritmy zdrojů</string>
|
||||
<string name="feed_group_relays">Relé</string>
|
||||
<string name="add_dvm_to_favorites">Přidat algoritmus zdroje k oblíbeným</string>
|
||||
<string name="remove_dvm_from_favorites">Odebrat z oblíbených</string>
|
||||
<string name="favorite_dvms_title">Oblíbené algoritmy zdrojů</string>
|
||||
<string name="favorite_dvms_explainer">Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších.</string>
|
||||
<string name="favorite_dvms_empty">Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou.</string>
|
||||
<string name="dvm_home_status_requesting">Žádost %1$s o zdroj…</string>
|
||||
<string name="dvm_home_status_requesting_all">Žádost oblíbených algoritmů o zdroje…</string>
|
||||
<string name="dvm_home_status_processing">Zpracování zdroje…</string>
|
||||
<string name="dvm_home_status_payment_required">Tento algoritmus zdroje vyžaduje platbu</string>
|
||||
<string name="dvm_home_status_error">Algoritmus zdroje vrátil chybu</string>
|
||||
<string name="dvm_home_retry">Zkusit znovu</string>
|
||||
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
|
||||
<string name="private_message">Soukromá zpráva</string>
|
||||
<string name="public_message">Veřejná zpráva</string>
|
||||
@@ -1819,6 +1844,41 @@
|
||||
<string name="playback_actions_dialog_title">Přehrávání</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">HLS nahrávání</string>
|
||||
<string name="share_hls_video_drawer_description">Publikujte multi-rozlišení HLS na váš media server</string>
|
||||
<string name="hls_pick_video_primary">Vyberte video</string>
|
||||
<string name="hls_pick_video_helper">Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení.</string>
|
||||
<string name="hls_change_video">Změnit</string>
|
||||
<string name="hls_title_label">Titulek</string>
|
||||
<string name="hls_title_placeholder">Zadejte titulek videa</string>
|
||||
<string name="hls_description_label">Popis</string>
|
||||
<string name="hls_description_placeholder">O čem je toto video?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Důvod (nepovinné)</string>
|
||||
<string name="hls_codec_label">Kodek</string>
|
||||
<string name="hls_codec_h265">H.265 (lepší komprese)</string>
|
||||
<string name="hls_codec_fallback_notice">H.265 není na tomto zařízení dostupný — používá se H.264.</string>
|
||||
<string name="hls_renditions_label">Rozlišení</string>
|
||||
<string name="hls_renditions_source_format">Zdrojové rozlišení: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Bude vytvořeno: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s přeskočeno — nad zdrojem)</string>
|
||||
<string name="hls_rendition_above_source">nad zdrojem — bude přeskočeno</string>
|
||||
<string name="hls_publish_button">Publikovat HD video</string>
|
||||
<string name="hls_publishing_header_format">Publikování „%1$s“…</string>
|
||||
<string name="hls_state_transcoding_format">Překódování %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Nahrát</string>
|
||||
<string name="hls_state_uploading_format">Nahrávání %1$d z %2$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">Nahrávání %1$s (%2$d z %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">Nahráno %1$d z %2$d</string>
|
||||
<string name="hls_state_publishing">Publikování události…</string>
|
||||
<string name="hls_state_success_title">Video publikováno</string>
|
||||
<string name="hls_state_success_body">Vaše HD video je publikováno na Nostr.</string>
|
||||
<string name="hls_state_failure_title">Něco se pokazilo</string>
|
||||
<string name="hls_view_note">Zobrazit poznámku</string>
|
||||
<string name="hls_done">Hotovo</string>
|
||||
<string name="hls_try_again">Zkusit znovu</string>
|
||||
<string name="hls_draft_note_after_upload">Vytvořit poznámku po nahrání</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním.</string>
|
||||
<string name="hls_draft_note_button">Vytvořit poznámku</string>
|
||||
<string name="pack_actions_dialog_title">Akce balíčku</string>
|
||||
<string name="list_actions_dialog_title">Akce seznamu</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
|
||||
|
||||
@@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig
|
||||
und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string>
|
||||
<string name="public_chat">Öffentlicher Chat</string>
|
||||
<string name="marmot_group">MLS-Gruppe</string>
|
||||
<string name="public_chat_title">Öffentliche Chat Metadaten</string>
|
||||
<string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder
|
||||
kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen.
|
||||
@@ -395,6 +396,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="pictures">Bilder</string>
|
||||
<string name="shorts">Kurzvideos</string>
|
||||
<string name="longs">Videos</string>
|
||||
<string name="articles">Artikel</string>
|
||||
<string name="private_bookmarks">Private Lesezeichen</string>
|
||||
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
|
||||
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
|
||||
@@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
|
||||
<string name="remove_from_public_bookmarks">Aus den öffentlichen Lesezeichen entfernen</string>
|
||||
<string name="pinned_notes">Angeheftete Notizen</string>
|
||||
<string name="pinned_notes_explainer">Deine angepinnten Notizen</string>
|
||||
<string name="pin_to_profile">An Profil anheften</string>
|
||||
<string name="unpin_from_profile">Von Profil lösen</string>
|
||||
<string name="deleted_items_banner_title">%1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht.</string>
|
||||
<string name="deleted_items_banner_remove">Aus Liste entfernen</string>
|
||||
<string name="deleted_items_banner_dismiss">Verwerfen</string>
|
||||
<string name="bookmark_lists">Lesezeichenlisten</string>
|
||||
<string name="bookmark_list_icon_label">Symbol für Lesezeichenliste</string>
|
||||
<string name="bookmark_list_creation_screen_title">Neue Lesezeichenliste</string>
|
||||
@@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="call_failed_accept">Anruf konnte nicht angenommen werden</string>
|
||||
<string name="call_failed_session">Anrufsitzung konnte nicht erstellt werden</string>
|
||||
<string name="call_settings">Anrufeinstellungen</string>
|
||||
<string name="call_settings_enable_calls">Sprach- und Videoanrufe aktivieren</string>
|
||||
<string name="call_settings_enable_calls_description">Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert.</string>
|
||||
<string name="call_settings_video_quality">Videoqualität</string>
|
||||
<string name="call_settings_max_bitrate">Maximale Video-Bitrate</string>
|
||||
<string name="call_settings_turn_servers">TURN- / STUN-Server</string>
|
||||
@@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="geohash_exclusive_explainer">Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string>
|
||||
<string name="hashtag_exclusive_explainer">Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen.</string>
|
||||
<string name="long_form_reading_minutes">%1$d Min. Lesezeit</string>
|
||||
<string name="loading_location">Standort wird geladen</string>
|
||||
<string name="lack_location_permissions">Keine Standortberechtigungen</string>
|
||||
<string name="add_sensitive_content_explainer">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten</string>
|
||||
@@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="new_feature_nip17_might_not_be_available_description">Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet.</string>
|
||||
<string name="new_feature_nip17_activate">Aktivieren</string>
|
||||
<string name="messages_create_public_chat">Öffentlich</string>
|
||||
<string name="messages_create_group">Gruppe</string>
|
||||
<string name="messages_create_public_private_chat_description">Neue öffentliche oder private Gruppe</string>
|
||||
<string name="messages_relay_based">Relais</string>
|
||||
<string name="messages_new_message">Privat</string>
|
||||
@@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="private_inbox_section">DM-Posteingangsrelais</string>
|
||||
<string name="private_inbox_section_explainer_profile">Der Benutzer empfängt Direktnachrichten auf diesen Relays</string>
|
||||
<string name="private_inbox_section_explainer">Fügen Sie 1–3 Relais ein, die als Ihr privater Posteingang dienen sollen. Andere werden diese Relais verwenden, um Ihnen DMs zu senden. DM-Posteingangsrelais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt)</string>
|
||||
<string name="keypackage_section">KeyPackage-Relays</string>
|
||||
<string name="keypackage_section_explainer">Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben.</string>
|
||||
<string name="private_outbox_section">Private Relais</string>
|
||||
<string name="private_outbox_section_explainer">Fügen Sie zwischen 1–3 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen.</string>
|
||||
<string name="kind_3_section">Allgemeine Relais</string>
|
||||
@@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="feed_group_locations">Standorte</string>
|
||||
<string name="feed_group_communities">Gemeinschaften</string>
|
||||
<string name="feed_group_lists">Listen</string>
|
||||
<string name="feed_group_dvms">Feed-Algorithmen</string>
|
||||
<string name="follow_list_all_favorite_dvms">Alle bevorzugten Feed-Algorithmen</string>
|
||||
<string name="feed_group_relays">Relais</string>
|
||||
<string name="add_dvm_to_favorites">Feed-Algorithmus zu Favoriten hinzufügen</string>
|
||||
<string name="remove_dvm_from_favorites">Aus Favoriten entfernen</string>
|
||||
<string name="favorite_dvms_title">Bevorzugte Feed-Algorithmen</string>
|
||||
<string name="favorite_dvms_explainer">Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen.</string>
|
||||
<string name="favorite_dvms_empty">Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen.</string>
|
||||
<string name="dvm_home_status_requesting">Frage %1$s nach einem Feed…</string>
|
||||
<string name="dvm_home_status_requesting_all">Frage deine bevorzugten Feed-Algorithmen nach Feeds…</string>
|
||||
<string name="dvm_home_status_processing">Feed wird verarbeitet…</string>
|
||||
<string name="dvm_home_status_payment_required">Dieser Feed-Algorithmus erfordert eine Zahlung</string>
|
||||
<string name="dvm_home_status_error">Der Feed-Algorithmus hat einen Fehler zurückgegeben</string>
|
||||
<string name="dvm_home_retry">Wiederholen</string>
|
||||
<string name="temporary_account">Beim Sperren des Geräts abmelden</string>
|
||||
<string name="private_message">Private Nachricht</string>
|
||||
<string name="public_message">Öffentliche Nachricht</string>
|
||||
@@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="playback_actions_dialog_title">Wiedergabe</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">HLS-Upload</string>
|
||||
<string name="share_hls_video_drawer_description">Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver</string>
|
||||
<string name="hls_pick_video_primary">Video auswählen</string>
|
||||
<string name="hls_pick_video_helper">Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten.</string>
|
||||
<string name="hls_change_video">Ändern</string>
|
||||
<string name="hls_title_label">Titel</string>
|
||||
<string name="hls_title_placeholder">Gib deinem Video einen Titel</string>
|
||||
<string name="hls_description_label">Beschreibung</string>
|
||||
<string name="hls_description_placeholder">Worum geht es in diesem Video?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Grund (optional)</string>
|
||||
<string name="hls_codec_h265">H.265 (bessere Komprimierung)</string>
|
||||
<string name="hls_codec_fallback_notice">H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264.</string>
|
||||
<string name="hls_renditions_label">Versionen</string>
|
||||
<string name="hls_renditions_source_format">Quellauflösung: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Wird erstellt: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s übersprungen — über Quelle)</string>
|
||||
<string name="hls_rendition_above_source">über Quelle — wird übersprungen</string>
|
||||
<string name="hls_publish_button">HD-Video veröffentlichen</string>
|
||||
<string name="hls_publishing_header_format">Veröffentliche „%1$s“…</string>
|
||||
<string name="hls_state_transcoding_format">Transkodiere %1$s</string>
|
||||
<string name="hls_state_uploading_idle">Hochladen</string>
|
||||
<string name="hls_state_uploading_format">Lade %1$d von %2$d hoch</string>
|
||||
<string name="hls_state_uploading_with_label_format">Lade %1$s hoch (%2$d von %3$d)</string>
|
||||
<string name="hls_state_uploaded_format">%1$d von %2$d hochgeladen</string>
|
||||
<string name="hls_state_publishing">Veröffentliche Ereignis…</string>
|
||||
<string name="hls_state_success_title">Video veröffentlicht</string>
|
||||
<string name="hls_state_success_body">Dein HD-Video ist auf Nostr live.</string>
|
||||
<string name="hls_state_failure_title">Etwas ist schiefgelaufen</string>
|
||||
<string name="hls_view_note">Notiz anzeigen</string>
|
||||
<string name="hls_done">Fertig</string>
|
||||
<string name="hls_try_again">Erneut versuchen</string>
|
||||
<string name="hls_draft_note_after_upload">Notiz nach Upload entwerfen</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst.</string>
|
||||
<string name="hls_draft_note_button">Notiz entwerfen</string>
|
||||
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
|
||||
<string name="list_actions_dialog_title">Listenaktionen</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
|
||||
|
||||
@@ -1350,6 +1350,7 @@
|
||||
<string name="private_inbox_section">सीधा संदेश आगतपेटिका पुनःप्रसारक</string>
|
||||
<string name="private_inbox_section_explainer_profile">प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है</string>
|
||||
<string name="private_inbox_section_explainer">आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क)</string>
|
||||
<string name="keypackages">कुंचिकापोटलियाँ</string>
|
||||
<string name="keypackage_section">कुंचिकापोटली पुनःप्रसारक</string>
|
||||
<string name="keypackage_section_explainer">पुनःप्रसारक जहाँ आपके एमएलएस॰ कुंचिकापोटलियाँ प्रकाशित होंगे (मिप॰००)। अन्य प्रयोक्ता इन कुंचिकापोटलियों को प्राप्त करेंगे आपको आमन्त्रित करने के लिए मार्मोट॰ समूह चर्चाओं में। १ - ३ पुनःप्रसारकों को जोडें जो आप से कुंचिकापोटली घटनाएँ स्वीकारते हों तथा सार्वजनिक पठन की अनुमती देते हों।</string>
|
||||
<string name="private_outbox_section">निजी पुनःप्रसारक</string>
|
||||
@@ -1851,6 +1852,25 @@
|
||||
<string name="hls_renditions_source_format">स्रोत सुलझाव : %1$dघ्न%2$d</string>
|
||||
<string name="hls_renditions_produce_format">उत्पाद्य : %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s त्यक्त - स्रोत ऊपर)</string>
|
||||
<string name="hls_rendition_bitrate_kbps_format">%1$d सहस्रांकप्रतिसेकण्ड</string>
|
||||
<string name="hls_rendition_above_source">स्रोत ऊपर - त्यक्तव्य</string>
|
||||
<string name="hls_publish_button">उच्छनिरूपण दृश्याभिलेख प्रकाशित करें</string>
|
||||
<string name="hls_publishing_header_format">प्रकाशन चालू \"%1$s\"…</string>
|
||||
<string name="hls_state_transcoding_format">संकुचनान्तरण चालू %1$s</string>
|
||||
<string name="hls_state_uploading_idle">आरोहण</string>
|
||||
<string name="hls_state_uploading_format">आरोहण चालू %2$d में से %1$d</string>
|
||||
<string name="hls_state_uploading_with_label_format">आरोहण चालू %1$s (%3$d में से %2$d)</string>
|
||||
<string name="hls_state_uploaded_format">आरोहण कृत %2$d में से %1$d</string>
|
||||
<string name="hls_state_publishing">घटना प्रकाशन चालू…</string>
|
||||
<string name="hls_state_success_title">दृश्याभिलेख प्रकाशित</string>
|
||||
<string name="hls_state_success_body">आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है।</string>
|
||||
<string name="hls_state_failure_title">कुछ गडबड हुआ</string>
|
||||
<string name="hls_view_note">टीका देखें</string>
|
||||
<string name="hls_done">हो गया</string>
|
||||
<string name="hls_try_again">पुनः प्रयास करें</string>
|
||||
<string name="hls_draft_note_after_upload">टीका सम्पादन आरोहण पश्चात</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व।</string>
|
||||
<string name="hls_draft_note_button">टीका सम्पादन</string>
|
||||
<string name="pack_actions_dialog_title">पोटली कार्य</string>
|
||||
<string name="list_actions_dialog_title">सूची कार्य</string>
|
||||
<string name="bookmark_item_actions_dialog_title">स्मर्त्तव्यचिह्न कार्य</string>
|
||||
|
||||
@@ -1350,6 +1350,9 @@
|
||||
<string name="private_inbox_section">Bejövő közvetlen üzenet-átjátszók</string>
|
||||
<string name="private_inbox_section_explainer_profile">A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket</string>
|
||||
<string name="private_inbox_section_explainer">Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős)</string>
|
||||
<string name="keypackages">Kulcscsomagok</string>
|
||||
<string name="keypackage_section">Kulcscsomag-átjátszók</string>
|
||||
<string name="keypackage_section_explainer">Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1-3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását.</string>
|
||||
<string name="private_outbox_section">Privát saját átjátszók</string>
|
||||
<string name="private_outbox_section_explainer">Adjon hozzá 1–3 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt.</string>
|
||||
<string name="kind_3_section">Általános átjátszók</string>
|
||||
|
||||
@@ -1346,6 +1346,7 @@
|
||||
<string name="private_inbox_section">Odbiorcze transmitery DM</string>
|
||||
<string name="private_inbox_section_explainer_profile">Użytkownik otrzymuje DM na tych transmiterach</string>
|
||||
<string name="private_inbox_section_explainer">Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny)</string>
|
||||
<string name="keypackages">Pakiety kluczy</string>
|
||||
<string name="keypackage_section">Transmitery pakietu kluczy</string>
|
||||
<string name="keypackage_section_explainer">Transmitery, na których publikowane są Twoje pakiety kluczy MLS (MIP-00). Inni użytkownicy pobierają te pakiety kluczy, aby zaprosić Cię do czatów grupowych w aplikacji Marmot. Wprowadź od 1 do 3 transmiterów, które akceptują zdarzenia związane z pakietami kluczy od Ciebie i zezwalają na publiczny dostęp do tych danych.</string>
|
||||
<string name="private_outbox_section">Transmitery Prywatne</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user