Adds support for seeing NIP-89 recommendations on User's profile.

This commit is contained in:
Vitor Pamplona 2023-06-03 12:39:06 -04:00
parent e28cd2212e
commit 00e7add001
15 changed files with 467 additions and 49 deletions

View File

@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
@ -13,7 +11,6 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@ -23,12 +20,6 @@ import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>(5000)
@ -130,18 +121,10 @@ object LocalCache {
// new event
val oldUser = getOrCreateUser(event.pubKey)
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
metadataParser.readValue(
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
return
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) {
oldUser.updateUserInfo(newUserMetadata, event)
}
oldUser.updateUserInfo(newUser, event)
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@ -357,6 +340,30 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: AppDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: AppRecommendationEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// // Log.d("RR", event.toJson())
@ -973,6 +980,8 @@ object LocalCache {
try {
when (event) {
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)

View File

@ -390,6 +390,10 @@ class UserMetadata {
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }

View File

@ -81,9 +81,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
limit = 100
)
)
}

View File

@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.UserMetadata
import java.io.ByteArrayInputStream
@Immutable
class AppDefinitionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun appMetaData() = try {
MetadataEvent.metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
runCatching { it[1].toInt() }.getOrNull()
}
fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)
companion object {
const val kind = 31990
}
}

View File

@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class AppRecommendationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
ATag.parse(it[1], it.getOrNull(2))
}
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 31989
}
}

View File

@ -228,6 +228,8 @@ open class Event(
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)

View File

@ -1,22 +1,17 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.io.ByteArrayInputStream
import java.util.Date
@Immutable
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?
)
abstract class IdentityClaim(
var identity: String,
var proof: String
@ -145,9 +140,13 @@ class MetadataEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun contactMetaData() = try {
gson.fromJson(content, ContactMetaData::class.java)
metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse $content", e)
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
@ -164,8 +163,10 @@ class MetadataEvent(
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {

View File

@ -0,0 +1,21 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
class UserProfileAppRecommendationsFeedFilter(val user: User) : FeedFilter<Note>() {
override fun feed(): List<Note> {
val recommendations = LocalCache.addressables.values.filter {
(it.event as? AppRecommendationEvent)?.pubKey == user.pubkeyHex
}.mapNotNull {
(it.event as? AppRecommendationEvent)?.recommendations()
}.flatten()
.mapNotNull {
LocalCache.getOrCreateAddressableNote(it)
}.toSet().toList()
return recommendations
}
}

View File

@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
@ -18,7 +19,7 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val longFormNotes = LocalCache.addressables.values
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent) }
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
return user?.notes
?.plus(longFormNotes)

View File

@ -40,7 +40,9 @@ import androidx.compose.material.Text
import androidx.compose.material.darkColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.lightColors
@ -67,6 +69,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@ -88,6 +91,8 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
@ -110,6 +115,7 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@ -120,10 +126,12 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZoomableContent
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
@ -469,6 +477,10 @@ fun NormalNote(
}
when (noteEvent) {
is AppDefinitionEvent -> {
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is ReactionEvent -> {
RenderReaction(baseNote, backgroundColor, accountViewModel, nav)
}
@ -669,6 +681,190 @@ private fun RenderPoll(
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RenderAppDefinition(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event as? AppDefinitionEvent ?: return
val clipboardManager = LocalClipboardManager.current
val uri = LocalUriHandler.current
var metadata by remember {
mutableStateOf<UserMetadata?>(null)
}
LaunchedEffect(key1 = noteEvent) {
launch(Dispatchers.Default) {
metadata = noteEvent.appMetaData()
}
}
metadata?.let {
Box {
if (!it.banner.isNullOrBlank()) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
AsyncImage(
model = it.banner,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
.combinedClickable(
onClick = {},
onLongClick = {
clipboardManager.setText(AnnotatedString(it.banner!!))
}
)
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false })
}
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box(Modifier.size(100.dp)) {
it.picture?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.border(
3.dp,
MaterialTheme.colors.background,
CircleShape
)
.clip(shape = CircleShape)
.fillMaxSize()
.border(
3.dp,
MaterialTheme.colors.background,
CircleShape
)
.combinedClickable(
onClick = { zoomImageDialogOpen = true },
onLongClick = {
clipboardManager.setText(AnnotatedString(it))
}
)
)
}
}
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false })
}
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier
.height(35.dp)
.padding(bottom = 3.dp)
) {
}
}
it.anyName()?.let {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = remember { note.event?.tags() ?: emptyList() },
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = note.idDisplayNote(),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
IconButton(
modifier = Modifier
.size(25.dp)
.padding(start = 5.dp),
onClick = { clipboardManager.setText(AnnotatedString(note.idDisplayNote())); }
) {
Icon(
imageVector = Icons.Default.ContentCopy,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
// DisplayNip05ProfileStatus(user)
val website = it.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.Link,
contentDescription = stringResource(R.string.website),
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { website.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
}
it.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = remember { note.event?.tags() ?: emptyList() },
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
}
}
@Composable
private fun RenderHighlight(
note: Note,
@ -2224,7 +2420,9 @@ fun NoteAuthorPicture(
if (author == null) {
val nullModifier = remember {
modifier.size(size).clip(shape = CircleShape)
modifier
.size(size)
.clip(shape = CircleShape)
}
RobohashAsyncImage(

View File

@ -33,7 +33,6 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalMaterialApi::class)
@Composable
@ -144,7 +143,6 @@ private fun WatchScrollToTop(
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,

View File

@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
@ -23,6 +24,7 @@ import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
@ -103,6 +105,14 @@ class NostrHomeRepliesFeedViewModel(val account: Account) : FeedViewModel(HomeCo
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter)
class NostrUserAppRecommendationsFeedViewModel(val user: User) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) {
class Factory(val user: User) : ViewModelProvider.Factory {
override fun <NostrUserAppRecommendationsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserAppRecommendationsFeedViewModel>): NostrUserAppRecommendationsFeedViewModel {
return NostrUserAppRecommendationsFeedViewModel(user) as NostrUserAppRecommendationsFeedViewModel
}
}
}
@Stable
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)

View File

@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
@ -359,7 +360,14 @@ fun NoteMaster(
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, accountViewModel, nav)
} else if (noteEvent is PinListEvent) {
PinListHeader(baseNote, MaterialTheme.colors.background, accountViewModel, nav)
PinListHeader(
baseNote,
MaterialTheme.colors.background,
accountViewModel,
nav
)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),

View File

@ -4,6 +4,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim
@ -78,7 +81,9 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel
@ -141,10 +146,18 @@ fun PrepareViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (
)
)
val appRecommendations: NostrUserAppRecommendationsFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel",
factory = NostrUserAppRecommendationsFeedViewModel.Factory(
baseUser
)
)
ProfileScreen(
baseUser = baseUser,
followsFeedViewModel,
followersFeedViewModel,
appRecommendations,
accountViewModel = accountViewModel,
nav = nav
)
@ -156,6 +169,7 @@ fun ProfileScreen(
baseUser: User,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@ -236,7 +250,7 @@ fun ProfileScreen(
.fillMaxHeight()
) {
Column(modifier = Modifier.padding()) {
ProfileHeader(baseUser, nav, accountViewModel)
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
@ -279,9 +293,9 @@ fun ProfileScreen(
2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
3 -> TabFollows(baseUser, followersFeedViewModel, accountViewModel, nav)
4 -> TabReceivedZaps(baseUser, accountViewModel, nav)
5 -> TabBookmarks(baseUser, accountViewModel, nav)
6 -> TabReports(baseUser, accountViewModel, nav)
7 -> TabRelays(baseUser, accountViewModel)
6 -> TabBookmarks(baseUser, accountViewModel, nav)
7 -> TabReports(baseUser, accountViewModel, nav)
8 -> TabRelays(baseUser, accountViewModel)
}
}
}
@ -397,6 +411,7 @@ private fun FollowTabHeader(baseUser: User) {
@Composable
private fun ProfileHeader(
baseUser: User,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@ -486,7 +501,7 @@ private fun ProfileHeader(
}
}
DrawAdditionalInfo(baseUser, accountViewModel, nav)
DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav)
Divider(modifier = Modifier.padding(top = 6.dp))
}
@ -555,7 +570,12 @@ private fun ProfileActions(
}
@Composable
private fun DrawAdditionalInfo(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
private fun DrawAdditionalInfo(
baseUser: User,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
@ -704,6 +724,8 @@ private fun DrawAdditionalInfo(baseUser: User, accountViewModel: AccountViewMode
)
}
}
DisplayAppRecommendations(appRecommendations, nav)
}
@Composable
@ -784,6 +806,81 @@ private fun DisplayLNAddress(
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplayAppRecommendations(
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
nav: (String) -> Unit
) {
val feedState by appRecommendations.feedContent.collectAsState()
LaunchedEffect(key1 = Unit) {
appRecommendations.invalidateData()
}
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Loaded -> {
Column() {
Text(stringResource(id = R.string.recommended_apps))
FlowRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
state.feed.value.forEach { app ->
WatchApp(app, nav)
}
}
}
}
else -> {}
}
}
}
@Composable
private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
val appState by baseApp.live().metadata.observeAsState()
var appLogo by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = appState) {
launch(Dispatchers.Default) {
val newAppLogo = (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null }
if (newAppLogo != appLogo) {
appLogo = newAppLogo
}
}
}
appLogo?.let {
Box(
remember {
Modifier
.size(35.dp)
.clickable {
nav("Note/${baseApp.idHex}")
}
}
) {
AsyncImage(
model = appLogo,
contentDescription = null,
modifier = remember {
Modifier
.size(35.dp)
.clip(shape = CircleShape)
}
)
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplayBadges(
@ -906,7 +1003,7 @@ fun BadgeThumb(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DrawBanner(baseUser: User) {
public fun DrawBanner(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState()
val banner = remember(userState) { userState?.user?.info?.banner }
@ -922,12 +1019,11 @@ private fun DrawBanner(baseUser: User) {
.fillMaxWidth()
.height(125.dp)
.combinedClickable(
onClick = {},
onClick = { zoomImageDialogOpen = true },
onLongClick = {
clipboardManager.setText(AnnotatedString(banner))
}
)
.clickable { zoomImageDialogOpen = true }
)
if (zoomImageDialogOpen) {

View File

@ -410,4 +410,6 @@
<string name="content_warning_hide_all_sensitive_content">Always hide sensitive content</string>
<string name="content_warning_show_all_sensitive_content">Always show sensitive content</string>
<string name="content_warning_see_warnings">Always show content warnings</string>
<string name="recommended_apps">Recommends: </string>
</resources>