mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge pull request #3192 from vitorpamplona/claude/eager-ptolemy-rwso2r
Add NIP-89 app recommendation management UI
This commit is contained in:
@@ -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)
|
||||
|
||||
+161
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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) }
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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() }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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)
|
||||
}
|
||||
+45
@@ -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,
|
||||
)
|
||||
}
|
||||
+77
-8
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-17
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
|
||||
+12
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user