diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b11b13d01b..01e3c82407 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt new file mode 100644 index 0000000000..f341af4e10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt @@ -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 = + 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> = + cache + .observeEvents( + 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 { + 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)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 50c4684257..9a7c1b5342 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f1f639464e..3a996974ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } + composableFromEnd { ProfileAppRecommendationsScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { WorkoutsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 48ef26c9b4..49b7f9766c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index d5e41d8f3a..b9340f3568 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 70b421d3b8..c34b535e12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 469f6fa9d6..06810eee1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -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 " 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppRecommendation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppRecommendation.kt new file mode 100644 index 0000000000..e819c1b631 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppRecommendation.kt @@ -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) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt new file mode 100644 index 0000000000..c7d403f383 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -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 { 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(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): 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt new file mode 100644 index 0000000000..ab1c3b4def --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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, +): List { + 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): List { + if (relays.isEmpty()) return emptyList() + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + limit = 100, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt new file mode 100644 index 0000000000..bbce9cc824 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt @@ -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() { + val group = + listOf( + ProfileAppRecommendationsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..28e7c463b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssemblerSubscription.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsSubAssembler.kt new file mode 100644 index 0000000000..60e2ab5810 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsSubAssembler.kt @@ -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, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: ProfileAppRecommendationsQueryState) = key.account.userProfile() + + override fun updateFilter( + key: ProfileAppRecommendationsQueryState, + since: SincePerRelayMap?, + ): List = + filterMyAppRecommendations( + pubkey = user(key).pubkeyHex, + relays = key.account.outboxRelays.flow.value, + ) + + filterRecentAppDefinitions( + relays = key.account.defaultGlobalRelays.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt index 7472571ced..f1ca7028aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt @@ -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) } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index c55f6e580f..a13bebfbe1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 0872ca7095..199d457730 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d24f306852..2e24826298 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -663,6 +663,22 @@ Badges · %1$d Choose which of the badges you\'ve received appear on your profile. You haven\'t received any badges yet. + Apps · %1$d + Apps + Recommend the Nostr apps you use so others can discover them. + Recommended apps + Choose which Nostr apps you publicly recommend. Recommendations appear on your profile and help others discover apps for content this client can\'t open. + No apps found yet. Apps will appear here as they are discovered on your relays. + Unnamed app + Doesn\'t announce what content it handles + Recommend + Recommended + Handles + by + Available on + Web + Android + iOS Pictures Workouts Workout diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index ffe06c375d..260ef53494 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt index 7e6e89e756..d2c0792df0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt @@ -75,5 +75,17 @@ class AppRecommendationEvent( alt(ALT_DESCRIPTION) initializer() } + + fun buildFromTags( + supportedKind: String, + recommendations: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(supportedKind) + recommend(recommendations) + alt(ALT_DESCRIPTION) + initializer() + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEventTest.kt new file mode 100644 index 0000000000..f1efe5fed4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEventTest.kt @@ -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) + } +}