Merge pull request #3192 from vitorpamplona/claude/eager-ptolemy-rwso2r

Add NIP-89 app recommendation management UI
This commit is contained in:
Vitor Pamplona
2026-06-12 13:33:38 -04:00
committed by GitHub
21 changed files with 1250 additions and 33 deletions
@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
@@ -392,6 +393,7 @@ class Account(
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
val interestSets = InterestSetsState(signer, cache, scope)
val appRecommendations = AppRecommendationsState(signer, cache, scope)
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
val bookmarkState = BookmarkListState(signer, cache, scope)
val pinState = PinListState(signer, cache, scope)
@@ -0,0 +1,161 @@
/*
* 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.nip89AppHandlers
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* This user's public NIP-89 app recommendations: one kind 31989 addressable
* event per handled kind, each listing the recommended apps for that kind.
*/
class AppRecommendationsState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
/**
* Synchronous cache scan. Seeds [flow] and feeds the read-modify-write
* publishers below, which must read current truth from the cache while
* holding [publishMutex].
*/
fun existingRecommendationEvents(): List<AppRecommendationEvent> =
cache.addressables
.filterIntoSet(AppRecommendationEvent.KIND, signer.pubKey)
.mapNotNull { it.event as? AppRecommendationEvent }
/**
* My kind 31989 recommendation events (one per handled kind), kept in
* sync as the cache consumes new versions. UI should collect this
* instead of rescanning the cache on every event bundle.
*
* Eagerly started on purpose, like the sibling account states: the
* registered observer holds strong references to these notes, pinning
* them in the soft-reference cache so the read-modify-write publishers
* below never rebuild a 31989 from a partially garbage-collected
* snapshot (which would silently drop previously recommended apps).
*/
val flow: StateFlow<List<AppRecommendationEvent>> =
cache
.observeEvents<AppRecommendationEvent>(
Filter(kinds = listOf(AppRecommendationEvent.KIND), authors = listOf(signer.pubKey)),
).flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, existingRecommendationEvents())
/**
* Serializes read-modify-write of the per-kind recommendation events so
* two rapid toggles can't race each other into losing updates.
*/
private val publishMutex = Mutex()
/**
* Returns a createdAt strictly greater than whatever AppRecommendationEvent
* currently sits in cache for this d-tag. Needed because
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
* strictly greater, and TimeUtils.now() has only second resolution.
*/
private fun nextCreatedAt(supportedKind: String): Long {
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L
return maxOf(TimeUtils.now(), latest + 1)
}
private fun currentRecommendations(supportedKind: String): List<RecommendationTag> {
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
val event = cache.getAddressableNoteIfExists(address)?.event as? AppRecommendationEvent
return event?.recommendations() ?: emptyList()
}
/**
* Adds [app] to this user's public NIP-89 recommendations, one kind 31989
* event per event kind the app declares to handle via `k` tags.
*/
suspend fun recommendApp(
app: AppDefinitionEvent,
relayHint: NormalizedRelayUrl?,
account: Account,
) {
if (!account.isWriteable()) return
val kinds = app.supportedKinds()
if (kinds.isEmpty()) return
val newTag = RecommendationTag(app.address(), relayHint, PlatformType.ANDROID.code)
publishMutex.withLock {
kinds.forEach { kind ->
val supportedKind = kind.toString()
val current = currentRecommendations(supportedKind)
if (current.any { it.address == app.address() }) return@forEach
val template =
AppRecommendationEvent.buildFromTags(
supportedKind = supportedKind,
recommendations = current + newTag,
createdAt = nextCreatedAt(supportedKind),
)
account.sendMyPublicAndPrivateOutbox(account.signer.sign(template))
}
}
}
/** Removes the app at [address] from every kind 31989 recommendation event of this user. */
suspend fun unrecommendApp(
address: Address,
account: Account,
) {
if (!account.isWriteable()) return
publishMutex.withLock {
existingRecommendationEvents().forEach { event ->
val current = event.recommendations()
val updated = current.filterNot { it.address == address }
if (updated.size == current.size) return@forEach
val template =
AppRecommendationEvent.buildFromTags(
supportedKind = event.dTag(),
recommendations = updated,
createdAt = nextCreatedAt(event.dTag()),
)
account.sendMyPublicAndPrivateOutbox(account.signer.sign(template))
}
}
}
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource.ProfileAppRecommendationsFilterAssembler
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
@@ -140,6 +141,7 @@ class RelaySubscriptionsCoordinator(
val softwareApps = SoftwareAppsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val profileAppRecommendations = ProfileAppRecommendationsFilterAssembler(client)
val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client)
val communitiesList = CommunitiesListFilterAssembler(client)
@@ -188,6 +190,7 @@ class RelaySubscriptionsCoordinator(
softwareApps,
badges,
profileBadges,
profileAppRecommendations,
browseEmojiSets,
communitiesList,
channelFinder,
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
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.apps.recommendations.ProfileAppRecommendationsScreen
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
@@ -278,6 +279,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.EditCommunity> { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
@@ -100,7 +100,9 @@ fun routeForInner(
if (noteEvent.includeKind(5300)) {
Route.ContentDiscovery(noteEvent.id)
} else {
Route.Note(noteEvent.id)
// By address, not version id: the per-id note may have been
// evicted and relays don't serve replaceables by old ids.
Route.Note(noteEvent.addressTag())
}
}
@@ -67,6 +67,8 @@ sealed class Route {
@Serializable object ProfileBadges : Route()
@Serializable object ProfileAppRecommendations : Route()
@Serializable data class AwardBadge(
val kind: Int,
val pubKeyHex: HexKey,
@@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAppRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
@@ -313,6 +314,7 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -890,6 +892,10 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is AppRecommendationEvent -> {
RenderAppRecommendation(baseNote, accountViewModel, nav)
}
is SoftwareApplicationEvent -> {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
}
@@ -24,19 +24,30 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -53,9 +64,11 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -68,20 +81,26 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.LinkIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun RenderAppDefinition(
note: Note,
@@ -200,11 +219,14 @@ fun RenderAppDefinition(
Spacer(Modifier.weight(1f))
Row(
modifier =
Modifier
.height(Size35dp)
.padding(bottom = 3.dp),
) {}
// Cancels the surrounding Column's horizontal padding so
// the button's right edge lines up with the banner's.
modifier = Modifier.padding(bottom = 3.dp).offset(x = 10.dp),
) {
if (accountViewModel.account.isWriteable()) {
RecommendAppButton(noteEvent, note, accountViewModel)
}
}
}
val name = remember(theAppMetadata) { theAppMetadata.anyName() }
@@ -225,6 +247,10 @@ fun RenderAppDefinition(
}
}
Row(modifier = Modifier.padding(top = 4.dp)) {
ByAuthorChip(noteEvent.pubKey, accountViewModel, nav)
}
val website = remember(theAppMetadata) { theAppMetadata.website }
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -261,7 +287,188 @@ fun RenderAppDefinition(
)
}
}
val platforms = remember(noteEvent) { noteEvent.platformLinks().map { it.platform }.distinct() }
if (platforms.isNotEmpty()) {
Text(
text = stringRes(R.string.app_definition_available_on),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 6.dp),
) {
platforms.forEach { PlatformChip(it) }
}
}
val supportedKinds = remember(noteEvent) { noteEvent.supportedKinds() }
if (supportedKinds.isNotEmpty()) {
Text(
text = stringRes(R.string.app_definition_handles),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 6.dp, bottom = 5.dp),
) {
val visible = supportedKinds.take(VISIBLE_SUPPORTED_KIND_LIMIT)
visible.forEach { KindChip(it) }
val overflow = supportedKinds.size - VISIBLE_SUPPORTED_KIND_LIMIT
if (overflow > 0) {
OverflowChip(overflow)
}
}
}
}
}
}
}
private const val VISIBLE_SUPPORTED_KIND_LIMIT = 12
/** Same shape and metrics as [KindChip] so it lines up with the kind chips. */
@Composable
private fun OverflowChip(count: Int) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
text = "+$count",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun PlatformChip(platform: String) {
val name =
when (platform) {
PlatformType.WEB.code -> stringRes(R.string.platform_web)
PlatformType.ANDROID.code -> stringRes(R.string.platform_android)
PlatformType.IOS.code -> stringRes(R.string.platform_ios)
else -> platform
}
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Text(
text = name,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
/**
* "by <picture> <name>" pill identifying the pubkey that published the app
* definition, so users can tell an app from a friend apart from a spammer
* impersonating the real one. The picture carries the following-checkmark
* overlay for people the account follows. Tapping opens the author's profile.
*/
@Composable
fun ByAuthorChip(
authorHex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { author ->
if (author == null) return@LoadUser
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.clip(RoundedCornerShape(50))
.clickable { nav.nav(routeFor(author)) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp),
) {
Text(
text = stringRes(R.string.app_definition_by),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(5.dp))
BaseUserPicture(author, 18.dp, accountViewModel)
Spacer(modifier = Modifier.size(5.dp))
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
UsernameDisplay(
baseUser = author,
fontWeight = FontWeight.SemiBold,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
accountViewModel = accountViewModel,
)
}
}
}
}
}
/**
* Shows whether the logged-in user publicly recommends this app (NIP-89,
* kind 31989) and toggles the recommendation on tap.
*/
@Composable
private fun RecommendAppButton(
noteEvent: AppDefinitionEvent,
note: Note,
accountViewModel: AccountViewModel,
) {
val myRecommendations by accountViewModel.account.appRecommendations.flow
.collectAsStateWithLifecycle()
val isRecommended =
remember(myRecommendations, noteEvent) {
val address = noteEvent.addressTag()
myRecommendations.any { event ->
event.recommendationAddresses().any { it == address }
}
}
val compactHeight = Modifier.height(32.dp)
val compactPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
if (isRecommended) {
OutlinedButton(
onClick = {
accountViewModel.launchSigner {
val account = accountViewModel.account
account.appRecommendations.unrecommendApp(noteEvent.address(), account)
}
},
modifier = compactHeight,
contentPadding = compactPadding,
) {
Text(stringRes(R.string.app_definition_recommended), style = MaterialTheme.typography.labelMedium)
}
} else {
Button(
enabled = noteEvent.supportedKinds().isNotEmpty(),
onClick = {
accountViewModel.launchSigner {
val account = accountViewModel.account
account.appRecommendations.recommendApp(noteEvent, note.relayHintUrl(), account)
}
},
modifier = compactHeight,
contentPadding = compactPadding,
) {
Text(stringRes(R.string.app_definition_recommend), style = MaterialTheme.typography.labelMedium)
}
}
}
@@ -0,0 +1,98 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.AppRecommendationChip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
/**
* Renders a kind 31989 NIP-89 recommendation: the list of apps the author
* recommends for one event kind, as tappable logo+name chips.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderAppRecommendation(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? AppRecommendationEvent ?: return
val recommendations = remember(noteEvent) { noteEvent.recommendations() }
val targetKind = remember(noteEvent) { noteEvent.dTag().toIntOrNull() }
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringRes(R.string.profile_app_recommendations_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
targetKind?.let {
Spacer(modifier = Modifier.size(6.dp))
KindChip(it)
}
}
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
recommendations.forEach { recommendation ->
LoadAddressableNote(recommendation.address, accountViewModel) { appNote ->
appNote?.let {
if (it.event == null) {
EventFinderFilterAssemblerSubscription(it, accountViewModel)
}
AppRecommendationChip(it, accountViewModel, nav)
}
}
}
}
}
}
@@ -0,0 +1,325 @@
/*
* 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.apps.recommendations
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.CircleShape
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.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.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.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filterIntoSet
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.types.ByAuthorChip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource.ProfileAppRecommendationsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ProfileAppRecommendationsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val myPubkey = accountViewModel.userProfile().pubkeyHex
// Pull my kind 31989 events plus recent kind 31990 app definitions from
// relays so the list below has candidates while this screen is open.
ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel)
// Ticks whenever LocalCache emits a bundle with a new app definition, so
// the candidate snapshot below recomputes.
var appDefinitionsTick by remember { mutableIntStateOf(0) }
LaunchedEffect(myPubkey) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { bundle ->
if (bundle.any { it.event is AppDefinitionEvent }) appDefinitionsTick++
}
}
}
val myRecommendationEvents by accountViewModel.account.appRecommendations.flow
.collectAsStateWithLifecycle()
val recommendedAddresses =
remember(myRecommendationEvents) {
myRecommendationEvents.flatMapTo(mutableSetOf()) { event -> event.recommendations().map { it.address } }
}
// The recommended set used for ORDERING only. It tracks recommendedAddresses
// while my 31989s stream in from relays, but freezes at the first toggle so
// rows don't jump around mid-edit. The next visit re-sorts with fresh data.
// Both pins start from the data already in cache (not empty) so the first
// frame after returning to this screen sorts the same as the last one;
// otherwise the restored LazyListState anchors to a key that then jumps
// down the list when the tiers kick in, dragging the viewport with it.
var userHasEdited by remember { mutableStateOf(false) }
var pinnedRecommended by remember { mutableStateOf(recommendedAddresses) }
LaunchedEffect(recommendedAddresses) {
if (!userHasEdited) pinnedRecommended = recommendedAddresses
}
// Tracks the follow list as it loads (it may not be ready when the screen
// opens), then freezes with the first toggle like pinnedRecommended.
val followsState by accountViewModel.account.kind3FollowList.flow
.collectAsStateWithLifecycle()
var pinnedFollows by remember { mutableStateOf(accountViewModel.account.kind3FollowList.flow.value.authors) }
LaunchedEffect(followsState) {
if (!userHasEdited) pinnedFollows = followsState.authors
}
val apps =
remember(appDefinitionsTick, pinnedRecommended, pinnedFollows) {
LocalCache.addressables
.filterIntoSet(AppDefinitionEvent.KIND) { _, note ->
val event = note.event as? AppDefinitionEvent ?: return@filterIntoSet false
// Unnamed apps are poor recommendation candidates; keep them
// only when already recommended, so they can be turned off.
note.address in pinnedRecommended ||
event
.appMetaData()
?.anyName()
?.isNotBlank() == true
}.sortedWith(
// Apps I recommend on top, then apps authored by people I
// follow, then the rest; most recent first within each tier.
compareByDescending<AddressableNote> { it.address in pinnedRecommended }
.thenByDescending { it.address.pubKeyHex in pinnedFollows }
.thenByDescending { it.event?.createdAt ?: 0 },
)
}
// Apps I recommend whose kind 31990 definition hasn't arrived yet: still
// listed so they can be turned off, while EventFinder fetches the details.
// Derived from the pinned set so a deselected row stays visible (switched
// off) instead of vanishing mid-edit.
val missingRecommended =
remember(pinnedRecommended, apps) {
val known = apps.mapTo(mutableSetOf()) { it.address }
pinnedRecommended
.filterNot { it in known }
.map { LocalCache.getOrCreateAddressableNote(it) }
}
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.profile_app_recommendations_title), nav)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Text(
text = stringRes(R.string.profile_app_recommendations_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
)
HorizontalDivider()
if (apps.isEmpty() && missingRecommended.isEmpty()) {
Text(
text = stringRes(R.string.profile_app_recommendations_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(20.dp),
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
items = missingRecommended + apps,
key = { it.idHex },
) { appNote ->
AppRow(
appNote = appNote,
isRecommended = appNote.address in recommendedAddresses,
onUserEdited = { userHasEdited = true },
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun AppRow(
appNote: AddressableNote,
isRecommended: Boolean,
onUserEdited: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Ask relays for the definition only if we don't have it yet, then watch.
// Subscribing for every visible row floods relays for nothing.
if (appNote.event == null) {
EventFinderFilterAssemblerSubscription(appNote, accountViewModel)
}
val definition by observeNoteEvent<AppDefinitionEvent>(appNote, accountViewModel)
val metadata = definition?.appMetaData()
val supportedKinds = definition?.supportedKinds() ?: emptyList()
val canToggle = definition != null && (isRecommended || supportedKinds.isNotEmpty())
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
routeFor(appNote, accountViewModel.account)?.let { nav.nav(it) }
}.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppLogo(definition)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = metadata?.anyName()?.ifBlank { null } ?: stringRes(R.string.app_definition_untitled),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(modifier = Modifier.padding(top = 2.dp, bottom = 2.dp)) {
ByAuthorChip(appNote.address.pubKeyHex, accountViewModel, nav)
}
metadata?.about?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (definition != null && supportedKinds.isEmpty()) {
Text(
text = stringRes(R.string.app_definition_no_supported_kinds),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (supportedKinds.isNotEmpty()) {
Text(
text = supportedKindsLabel(supportedKinds),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.size(12.dp))
Switch(
checked = isRecommended,
enabled = canToggle,
onCheckedChange = { checked ->
onUserEdited()
accountViewModel.launchSigner {
val account = accountViewModel.account
if (checked) {
val event = definition ?: return@launchSigner
account.appRecommendations.recommendApp(event, appNote.relayHintUrl(), account)
} else {
account.appRecommendations.unrecommendApp(appNote.address, account)
}
}
},
)
}
}
private const val VISIBLE_KIND_NAMES = 3
@Composable
private fun supportedKindsLabel(kinds: List<Int>): String {
val names =
kinds.take(VISIBLE_KIND_NAMES).map { kind ->
val nameRes = kindDisplayName(kind)
if (nameRes != -1) stringRes(nameRes) else "k$kind"
}
val overflow = kinds.size - VISIBLE_KIND_NAMES
val suffix = if (overflow > 0) " +$overflow" else ""
return stringRes(R.string.app_definition_handles) + ": " + names.joinToString(" · ") + suffix
}
@Composable
private fun AppLogo(definition: AppDefinitionEvent?) {
val imageUrl = definition?.appMetaData()?.profilePicture()?.ifBlank { null }
val logoModifier = Modifier.size(48.dp).clip(CircleShape)
if (imageUrl.isNullOrBlank()) {
RobohashAsyncImage(
robot = definition?.id ?: "appnotfound",
contentDescription = null,
modifier = logoModifier,
loadRobohash = true,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = logoModifier,
contentScale = ContentScale.Crop,
)
}
}
@@ -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.apps.recommendations.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.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
/**
* Pulls this user's own kind 31989 recommendation events from their outbox
* relays so the "Recommended apps" management screen starts from the current
* published state even when the profile screen hasn't been visited yet.
*/
fun filterMyAppRecommendations(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (pubkey.isEmpty() || relays.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AppRecommendationEvent.KIND),
authors = authors,
limit = 100,
),
)
}
}
/**
* Pulls recent kind 31990 app definitions so the management screen has
* candidates to recommend beyond what already happens to sit in cache.
*/
fun filterRecentAppDefinitions(relays: Set<NormalizedRelayUrl>): List<RelayBasedFilter> {
if (relays.isEmpty()) return emptyList()
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 100,
),
)
}
}
@@ -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.apps.recommendations.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 ProfileAppRecommendationsQueryState(
val account: Account,
)
@Stable
class ProfileAppRecommendationsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ProfileAppRecommendationsQueryState>() {
val group =
listOf(
ProfileAppRecommendationsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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.apps.recommendations.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
val state =
remember(accountViewModel.account) {
ProfileAppRecommendationsQueryState(accountViewModel.account)
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().profileAppRecommendations)
}
@@ -0,0 +1,45 @@
/*
* 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.apps.recommendations.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 ProfileAppRecommendationsSubAssembler(
client: INostrClient,
allKeys: () -> Set<ProfileAppRecommendationsQueryState>,
) : PerUserEoseManager<ProfileAppRecommendationsQueryState>(client, allKeys) {
override fun user(key: ProfileAppRecommendationsQueryState) = key.account.userProfile()
override fun updateFilter(
key: ProfileAppRecommendationsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
filterMyAppRecommendations(
pubkey = user(key).pubkeyHex,
relays = key.account.outboxRelays.flow.value,
) +
filterRecentAppDefinitions(
relays = key.account.defaultGlobalRelays.flow.value,
)
}
@@ -25,18 +25,29 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.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.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
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.stringRes
@@ -50,6 +61,8 @@ fun DisplayAppRecommendations(
LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() }
val isMe = appRecommendations.user.pubkeyHex == accountViewModel.userProfile().pubkeyHex
CrossfadeIfEnabled(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
@@ -57,10 +70,21 @@ fun DisplayAppRecommendations(
) { state ->
when (state) {
is FeedState.Loaded -> {
Column {
Text(stringRes(id = R.string.recommended_apps))
Recommends(state, isMe, accountViewModel, nav)
}
Recommends(state, accountViewModel, nav)
is FeedState.Empty -> {
// Owners see the section with the edit affordance even before
// their first recommendation, so the feature is discoverable.
if (isMe) {
Column(modifier = Modifier.padding(vertical = 6.dp)) {
AppsHeader(appCount = 0, isMe = true, nav = nav)
Text(
text = stringRes(R.string.profile_apps_empty_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -69,18 +93,63 @@ fun DisplayAppRecommendations(
}
}
@Composable
private fun AppsHeader(
appCount: Int,
isMe: Boolean,
nav: INav,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text =
if (appCount > 0) {
stringRes(R.string.profile_apps_header, appCount)
} else {
stringRes(R.string.profile_apps_header_empty)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (isMe) {
IconButton(
onClick = { nav.nav(Route.ProfileAppRecommendations) },
modifier = Modifier.size(32.dp),
) {
Icon(
symbol = MaterialSymbols.Settings,
contentDescription = stringRes(R.string.profile_app_recommendations_title),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
fun Recommends(
loaded: FeedState.Loaded,
isMe: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
FlowRow(
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp),
) {
items.list.forEach { app -> WatchApp(app, accountViewModel, nav) }
Column(modifier = Modifier.padding(vertical = 6.dp)) {
AppsHeader(appCount = items.list.size, isMe = isMe, nav = nav)
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items.list.forEach { app -> AppRecommendationChip(app, accountViewModel, nav) }
}
}
}
@@ -21,30 +21,43 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
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.theme.Size35dp
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* A compact pill with the app's logo and name, shown in the profile's
* "Apps" section. Tapping it opens the full app definition card.
*/
@Composable
fun WatchApp(
fun AppRecommendationChip(
baseApp: Note,
accountViewModel: AccountViewModel,
nav: INav,
@@ -57,33 +70,42 @@ fun WatchApp(
LaunchedEffect(key1 = appState) {
withContext(Dispatchers.IO) {
(appState.note.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData ->
metaData.picture?.ifBlank { null }?.let { newLogo ->
metaData.profilePicture()?.ifBlank { null }?.let { newLogo ->
if (newLogo != appLogo) appLogo = newLogo
}
metaData.name?.ifBlank { null }?.let { newName ->
metaData.anyName()?.ifBlank { null }?.let { newName ->
if (newName != appName) appName = newName
}
}
}
}
appLogo?.let {
Box(
remember {
Modifier
.size(Size35dp)
.clickable { nav.nav(Route.Note(baseApp.idHex)) }
},
if (appLogo == null && appName == null) return
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(50)).clickable { nav.nav(Route.Note(baseApp.idHex)) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
) {
AsyncImage(
model = appLogo,
contentDescription = appName,
modifier =
remember {
Modifier
.size(Size35dp)
.clip(shape = CircleShape)
},
Modifier
.size(22.dp)
.clip(shape = CircleShape),
)
Text(
text = appName ?: stringRes(R.string.app_definition_untitled),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 6.dp),
)
}
}
@@ -143,6 +143,7 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAppRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
@@ -308,6 +309,7 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
@@ -755,6 +757,8 @@ private fun FullBleedNoteCompose(
RenderGitIssueEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is AppRecommendationEvent) {
RenderAppRecommendation(baseNote, accountViewModel, nav)
} else if (noteEvent is SoftwareApplicationEvent) {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
} else if (noteEvent is SoftwareAssetEvent) {
+16
View File
@@ -663,6 +663,22 @@
<string name="profile_badges_header">Badges · %1$d</string>
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="profile_apps_header">Apps · %1$d</string>
<string name="profile_apps_header_empty">Apps</string>
<string name="profile_apps_empty_hint">Recommend the Nostr apps you use so others can discover them.</string>
<string name="profile_app_recommendations_title">Recommended apps</string>
<string name="profile_app_recommendations_description">Choose which Nostr apps you publicly recommend. Recommendations appear on your profile and help others discover apps for content this client can\'t open.</string>
<string name="profile_app_recommendations_empty">No apps found yet. Apps will appear here as they are discovered on your relays.</string>
<string name="app_definition_untitled">Unnamed app</string>
<string name="app_definition_no_supported_kinds">Doesn\'t announce what content it handles</string>
<string name="app_definition_recommend">Recommend</string>
<string name="app_definition_recommended">Recommended</string>
<string name="app_definition_handles">Handles</string>
<string name="app_definition_by">by</string>
<string name="app_definition_available_on">Available on</string>
<string name="platform_web">Web</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="pictures">Pictures</string>
<string name="workouts">Workouts</string>
<string name="workout">Workout</string>
@@ -81,6 +81,8 @@ class AppDefinitionEvent(
fun includeKind(kind: Int) = tags.isTaggedKind(kind)
fun platformLinks() = tags.mapNotNull(PlatformLinkTag::parse)
override fun publishedAt(): Long? {
val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
@@ -75,5 +75,17 @@ class AppRecommendationEvent(
alt(ALT_DESCRIPTION)
initializer()
}
fun buildFromTags(
supportedKind: String,
recommendations: List<RecommendationTag>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AppRecommendationEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(supportedKind)
recommend(recommendations)
alt(ALT_DESCRIPTION)
initializer()
}
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip89AppHandlers.recommendation
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class AppRecommendationEventTest {
val appAddress =
Address(
AppDefinitionEvent.KIND,
"1743058db7078661b94aaf4286429d97ee5257d14a86d6bfa54cb0482b876fb0",
"abcd",
)
val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
@Test
fun roundTripPreservesRelayAndPlatform() {
val original = RecommendationTag(appAddress, relay, "android")
val parsed = RecommendationTag.parse(original.toTagArray())
assertNotNull(parsed)
assertEquals(appAddress, parsed.address)
assertEquals(relay, parsed.relay)
assertEquals("android", parsed.platform)
}
@Test
fun platformSurvivesNullRelayHint() {
// assemble keeps a placeholder for the null relay, so the platform
// stays in its own slot instead of shifting into the relay position.
val tagArray = RecommendationTag(appAddress, null, "android").toTagArray()
val parsed = RecommendationTag.parse(tagArray)
assertNotNull(parsed)
assertNull(parsed.relay)
assertEquals("android", parsed.platform)
}
@Test
fun buildFromTagsKeepsExistingRecommendations() {
val otherApp =
Address(
AppDefinitionEvent.KIND,
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
"efgh",
)
val existing = RecommendationTag(appAddress, relay, "web")
val added = RecommendationTag(otherApp, relay, "android")
val template = AppRecommendationEvent.buildFromTags("31337", listOf(existing, added))
assertEquals(AppRecommendationEvent.KIND, template.kind)
assertEquals(listOf("d", "31337"), template.tags.first { it[0] == "d" }.toList())
val recommendations = template.tags.mapNotNull(RecommendationTag::parse)
assertEquals(2, recommendations.size)
assertEquals(appAddress, recommendations[0].address)
assertEquals("web", recommendations[0].platform)
assertEquals(otherApp, recommendations[1].address)
assertEquals("android", recommendations[1].platform)
}
}