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

View File

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

View File

@ -81,9 +81,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind), kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
authors = listOf(it.pubkeyHex), 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 fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) { 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) AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(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 package com.vitorpamplona.amethyst.service.model
import android.util.Log 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.google.gson.Gson
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils import nostr.postr.Utils
import java.io.ByteArrayInputStream
import java.util.Date import java.util.Date
@Immutable
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?
)
abstract class IdentityClaim( abstract class IdentityClaim(
var identity: String, var identity: String,
var proof: String var proof: String
@ -145,9 +140,13 @@ class MetadataEvent(
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun contactMetaData() = try { fun contactMetaData() = try {
gson.fromJson(content, ContactMetaData::class.java) metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse $content", e) e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null null
} }
@ -164,8 +163,10 @@ class MetadataEvent(
const val kind = 0 const val kind = 0
val gson = Gson() val gson = Gson()
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent { val metadataParser by lazy {
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt) 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 { 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.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.service.model.PeopleListEvent
@ -18,7 +19,7 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val longFormNotes = LocalCache.addressables.values 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 return user?.notes
?.plus(longFormNotes) ?.plus(longFormNotes)

View File

@ -40,7 +40,9 @@ import androidx.compose.material.Text
import androidx.compose.material.darkColors import androidx.compose.material.darkColors
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt 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.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.lightColors 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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString 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.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent 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.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji 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.LoadThumbAndThenVideoView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage 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.VideoView
import com.vitorpamplona.amethyst.ui.components.ZoomableContent import com.vitorpamplona.amethyst.ui.components.ZoomableContent
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView 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.ZoomableLocalImage
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo 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.components.imageExtensions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
@ -469,6 +477,10 @@ fun NormalNote(
} }
when (noteEvent) { when (noteEvent) {
is AppDefinitionEvent -> {
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is ReactionEvent -> { is ReactionEvent -> {
RenderReaction(baseNote, backgroundColor, accountViewModel, nav) 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 @Composable
private fun RenderHighlight( private fun RenderHighlight(
note: Note, note: Note,
@ -2224,7 +2420,9 @@ fun NoteAuthorPicture(
if (author == null) { if (author == null) {
val nullModifier = remember { val nullModifier = remember {
modifier.size(size).clip(shape = CircleShape) modifier
.size(size)
.clip(shape = CircleShape)
} }
RobohashAsyncImage( RobohashAsyncImage(

View File

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

View File

@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter 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.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter 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.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
@ -103,6 +105,14 @@ class NostrHomeRepliesFeedViewModel(val account: Account) : FeedViewModel(HomeCo
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter) class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter) 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 @Stable
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() { abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading) private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)

View File

@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note 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.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent
@ -359,7 +360,14 @@ fun NoteMaster(
} else if (noteEvent is AudioTrackEvent) { } else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, accountViewModel, nav) AudioTrackHeader(noteEvent, accountViewModel, nav)
} else if (noteEvent is PinListEvent) { } 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) { } else if (noteEvent is HighlightEvent) {
DisplayHighlight( DisplayHighlight(
noteEvent.quote(), noteEvent.quote(),

View File

@ -4,6 +4,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.* 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.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource 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.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim 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.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.UserPicture 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.LnZapFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel 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( ProfileScreen(
baseUser = baseUser, baseUser = baseUser,
followsFeedViewModel, followsFeedViewModel,
followersFeedViewModel, followersFeedViewModel,
appRecommendations,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav nav = nav
) )
@ -156,6 +169,7 @@ fun ProfileScreen(
baseUser: User, baseUser: User,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
@ -236,7 +250,7 @@ fun ProfileScreen(
.fillMaxHeight() .fillMaxHeight()
) { ) {
Column(modifier = Modifier.padding()) { Column(modifier = Modifier.padding()) {
ProfileHeader(baseUser, nav, accountViewModel) ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow( ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
@ -279,9 +293,9 @@ fun ProfileScreen(
2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
3 -> TabFollows(baseUser, followersFeedViewModel, accountViewModel, nav) 3 -> TabFollows(baseUser, followersFeedViewModel, accountViewModel, nav)
4 -> TabReceivedZaps(baseUser, accountViewModel, nav) 4 -> TabReceivedZaps(baseUser, accountViewModel, nav)
5 -> TabBookmarks(baseUser, accountViewModel, nav) 6 -> TabBookmarks(baseUser, accountViewModel, nav)
6 -> TabReports(baseUser, accountViewModel, nav) 7 -> TabReports(baseUser, accountViewModel, nav)
7 -> TabRelays(baseUser, accountViewModel) 8 -> TabRelays(baseUser, accountViewModel)
} }
} }
} }
@ -397,6 +411,7 @@ private fun FollowTabHeader(baseUser: User) {
@Composable @Composable
private fun ProfileHeader( private fun ProfileHeader(
baseUser: User, baseUser: User,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
accountViewModel: AccountViewModel 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)) Divider(modifier = Modifier.padding(top = 6.dp))
} }
@ -555,7 +570,12 @@ private fun ProfileActions(
} }
@Composable @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 userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
@ -704,6 +724,8 @@ private fun DrawAdditionalInfo(baseUser: User, accountViewModel: AccountViewMode
) )
} }
} }
DisplayAppRecommendations(appRecommendations, nav)
} }
@Composable @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 @Composable
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
private fun DisplayBadges( private fun DisplayBadges(
@ -906,7 +1003,7 @@ fun BadgeThumb(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun DrawBanner(baseUser: User) { public fun DrawBanner(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val banner = remember(userState) { userState?.user?.info?.banner } val banner = remember(userState) { userState?.user?.info?.banner }
@ -922,12 +1019,11 @@ private fun DrawBanner(baseUser: User) {
.fillMaxWidth() .fillMaxWidth()
.height(125.dp) .height(125.dp)
.combinedClickable( .combinedClickable(
onClick = {}, onClick = { zoomImageDialogOpen = true },
onLongClick = { onLongClick = {
clipboardManager.setText(AnnotatedString(banner)) clipboardManager.setText(AnnotatedString(banner))
} }
) )
.clickable { zoomImageDialogOpen = true }
) )
if (zoomImageDialogOpen) { 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_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_show_all_sensitive_content">Always show sensitive content</string>
<string name="content_warning_see_warnings">Always show content warnings</string> <string name="content_warning_see_warnings">Always show content warnings</string>
<string name="recommended_apps">Recommends: </string>
</resources> </resources>