diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d1059e3ea..6259bc6c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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(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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index fccb40fdf..34b86a3bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 5b9df5826..30ee695fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -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 ) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/AppDefinitionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AppDefinitionEvent.kt new file mode 100644 index 000000000..6a19c6d8a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AppDefinitionEvent.kt @@ -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>, + 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 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/AppRecommendationEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AppRecommendationEvent.kt new file mode 100644 index 000000000..6baa518cc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AppRecommendationEvent.kt @@ -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>, + 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 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 946f0bcba..68e206631 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt index 766d1e79d..1fe166cfc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt @@ -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, 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, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt new file mode 100644 index 000000000..a2134f028 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt @@ -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() { + override fun feed(): List { + 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 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 87e2e642a..36505b4ae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -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() { override fun feed(): List { 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 0adefca16..b09f375d2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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(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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index e9538e151..291ee3b4c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index db25ffd78..269b5c5c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -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 create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel { + return NostrUserAppRecommendationsFeedViewModel(user) as NostrUserAppRecommendationsFeedViewModel + } + } +} + @Stable abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(FeedState.Loading) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 7a587f631..16be111cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -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(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index c5978e175..1893c7caa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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(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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a2b1bef5..5666371e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,4 +410,6 @@ Always hide sensitive content Always show sensitive content Always show content warnings + + Recommends: