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 fcc1a8f46..359143220 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -6,6 +6,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.service.model.ATag +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -138,7 +141,6 @@ object LocalCache { } } - fun consume(event: MetadataEvent) { // new event val oldUser = getOrCreateUser(event.pubKey) @@ -252,6 +254,71 @@ object LocalCache { } } + fun consume(event: BadgeDefinitionEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event?.id == event.id) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList(), emptyList()) + + refreshObservers() + } + } + + fun consume(event: BadgeProfilesEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event?.id == event.id) return + + val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList(), replyTo) + + author.updateAcceptedBadges(note) + + refreshObservers() + } + } + + fun consume(event: BadgeAwardEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + //Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) } + val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, awardees, awardDefinition) + + // Adds notifications to users. + awardees.forEach { + it.addTaggedPost(note) + } + + // Counts the replies + awardees.forEach { + it.addBadgeAward(note) + } + + // Replies of an Badge Definition are Award Events + awardDefinition.forEach { + it.addReply(note) + } + + refreshObservers() + } + private fun findCitations(event: Event): Set { var citations = mutableSetOf() // Removes citations from replies: @@ -537,6 +604,7 @@ object LocalCache { // older data, does nothing } } + fun consume(event: ChannelMetadataEvent) { val channelId = event.channel() //Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") 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 b42f5b48c..153c6167d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -24,6 +24,8 @@ import nostr.postr.toNpub val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") +class Badges(val definition: Note, val awardees: Set) + class User(val pubkeyHex: String) { var info: UserMetadata? = null @@ -43,6 +45,7 @@ class User(val pubkeyHex: String) { var reports = mapOf>() private set + var latestReportTime: Long = 0 var zaps = mapOf() @@ -57,6 +60,11 @@ class User(val pubkeyHex: String) { var privateChatrooms = mapOf() private set + var badgeAwards = setOf() + private set + + var acceptedBadges: AddressableNote? = null + fun pubkey() = Hex.decode(pubkeyHex) fun pubkeyNpub() = pubkey().toNpub() fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() @@ -175,6 +183,23 @@ class User(val pubkeyHex: String) { } } + fun addBadgeAward(note: Note) { + if (note !in badgeAwards) { + badgeAwards = badgeAwards + note + liveSet?.badges?.invalidateData() + } + } + + fun removeBadgeAward(deleteNote: Note) { + badgeAwards = badgeAwards - deleteNote + liveSet?.badges?.invalidateData() + } + + fun updateAcceptedBadges(note: AddressableNote) { + acceptedBadges = note + liveSet?.badges?.invalidateData() + } + fun addZap(zapRequest: Note, zap: Note?) { if (zapRequest !in zaps.keys) { zaps = zaps + Pair(zapRequest, zap) @@ -316,6 +341,8 @@ class User(val pubkeyHex: String) { liveSet = null } } + + } class UserLiveSet(u: User) { @@ -327,6 +354,7 @@ class UserLiveSet(u: User) { val relayInfo: UserLiveData = UserLiveData(u) val metadata: UserLiveData = UserLiveData(u) val zaps: UserLiveData = UserLiveData(u) + val badges: UserLiveData = UserLiveData(u) fun isInUse(): Boolean { return follows.hasObservers() @@ -336,6 +364,7 @@ class UserLiveSet(u: User) { || relayInfo.hasObservers() || metadata.hasObservers() || zaps.hasObservers() + || badges.hasObservers() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index c1231921b..1ff1b4a41 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -1,6 +1,8 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent @@ -38,6 +40,17 @@ object NostrAccountDataSource: NostrDataSource("AccountData") { ) } + fun createAccountAcceptedAwardsFilter(): TypedFilter { + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BadgeProfilesEvent.kind), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1 + ) + ) + } + fun createAccountReportsFilter(): TypedFilter { return TypedFilter( types = FeedType.values().toSet(), @@ -52,7 +65,7 @@ object NostrAccountDataSource: NostrDataSource("AccountData") { types = FeedType.values().toSet(), filter = JsonFilter( kinds = listOf( - TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind + TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), limit = 200 @@ -67,7 +80,8 @@ object NostrAccountDataSource: NostrDataSource("AccountData") { createAccountMetadataFilter(), createAccountContactListFilter(), createNotificationFilter(), - createAccountReportsFilter() + createAccountReportsFilter(), + createAccountAcceptedAwardsFilter() ).ifEmpty { null } } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index ffdde932b..d248068dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -2,6 +2,9 @@ package com.vitorpamplona.amethyst.service import android.util.Log import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -62,6 +65,9 @@ abstract class NostrDataSource(val debugName: String) { try { when (event) { + is BadgeAwardEvent -> LocalCache.consume(event) + is BadgeDefinitionEvent -> LocalCache.consume(event) + is BadgeProfilesEvent -> LocalCache.consume(event) is ChannelCreateEvent -> LocalCache.consume(event) is ChannelHideMessageEvent -> LocalCache.consume(event) is ChannelMessageEvent -> LocalCache.consume(event, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index be233d97c..d40378425 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -1,6 +1,10 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -20,7 +24,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { private var eventsToWatch = setOf() private var addressesToWatch = setOf() - private fun createAddressFilter(): List? { + private fun createTagToAddressFilter(): List? { val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch if (addressesToWatch.isEmpty()) { @@ -38,7 +42,10 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { types = FeedType.values().toSet(), filter = JsonFilter( kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind + TextNoteEvent.kind, LongTextNoteEvent.kind, + ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, + LnZapEvent.kind, LnZapRequestEvent.kind, + BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind ), tags = mapOf("a" to listOf(aTag.toTag())), since = it.lastReactionsDownloadTime @@ -48,6 +55,32 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { } } + private fun createAddressFilter(): List? { + val addressesToWatch = addressesToWatch.filter { it.event == null } + + if (addressesToWatch.isEmpty()) { + return null + } + + val now = Date().time / 1000 + + return addressesToWatch.filter { + val lastTime = it.lastReactionsDownloadTime + lastTime == null || lastTime < (now - 10) + }.mapNotNull { + it.address()?.let { aTag -> + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex.substring(0,8)) + ) + ) + } + } + } + private fun createRepliesAndReactionsFilter(): List? { val reactionsToWatch = eventsToWatch @@ -81,7 +114,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { val threadingEventsToLoad = eventsToWatch .mapNotNull { it.replyTo } .flatten() - .filter { it.event == null } + .filter { it !is AddressableNote && it.event == null } val interestedEvents = (directEventsToLoad + threadingEventsToLoad) @@ -98,7 +131,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind + ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind ), ids = interestedEvents.toList() ) @@ -119,8 +152,9 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { val reactions = createRepliesAndReactionsFilter() val missing = createLoadEventsIfNotLoadedFilter() val addresses = createAddressFilter() + val addressReactions = createTagToAddressFilter() - singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses).flatten().ifEmpty { null } + singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null } } fun add(eventId: Note) { 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 8cfe59d40..26684b34f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType @@ -77,6 +79,28 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { ) } + fun createAcceptedAwardsFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BadgeProfilesEvent.kind), + tags = mapOf("p" to listOf(it.pubkeyHex)), + limit = 1 + ) + ) + } + + fun createReceivedAwardsFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BadgeAwardEvent.kind), + tags = mapOf("p" to listOf(it.pubkeyHex)), + limit = 20 + ) + ) + } + val userInfoChannel = requestNewChannel() override fun updateChannelFilters() { @@ -85,7 +109,9 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { createUserPostsFilter(), createFollowFilter(), createFollowersFilter(), - createUserReceivedZapsFilter() + createUserReceivedZapsFilter(), + createAcceptedAwardsFilter(), + createReceivedAwardsFilter() ).ifEmpty { null } } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt new file mode 100644 index 000000000..b0a3693a7 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt @@ -0,0 +1,22 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import java.util.Date +import nostr.postr.Utils + +class BadgeAwardEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + + companion object { + const val kind = 8 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt new file mode 100644 index 000000000..d3572d84d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt @@ -0,0 +1,27 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import java.util.Date +import nostr.postr.Utils + +class BadgeDefinitionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + fun address() = ATag(kind, pubKey, dTag()) + + fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + + companion object { + const val kind = 30009 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt new file mode 100644 index 000000000..9a6ce84a9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt @@ -0,0 +1,22 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey + +class BadgeProfilesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + + fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + fun address() = ATag(kind, pubKey, dTag()) + + companion object { + const val kind = 30008 + } +} 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 772ca170c..a9189aaf0 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 @@ -161,6 +161,10 @@ 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) { + BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) + BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 73fda4b2c..348981329 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -19,6 +19,8 @@ object NotificationFeedFilter: FeedFilter() { it.event !is ChannelCreateEvent && it.event !is ChannelMetadataEvent && it.event !is LnZapRequestEvent + && it.event !is BadgeDefinitionEvent + && it.event !is BadgeProfilesEvent } .filter { it -> it.event !is TextNoteEvent diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt new file mode 100644 index 000000000..20f31f5b9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -0,0 +1,141 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.MilitaryTech +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.accompanist.flowlayout.FlowRow +import com.vitorpamplona.amethyst.NotificationCache +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.ui.screen.BadgeCard +import com.vitorpamplona.amethyst.ui.screen.LikeSetCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) { + val noteState by likeSetCard.note.live().metadata.observeAsState() + val note = noteState?.note + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val context = LocalContext.current.applicationContext + + val noteEvent = note?.event + var popupExpanded by remember { mutableStateOf(false) } + + if (note == null) { + BlankNote(Modifier, isInnerNote) + } else { + var isNew by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = likeSetCard) { + isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead, context) + + NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt(), context) + } + + var backgroundColor = if (isNew) { + MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background) + } else { + MaterialTheme.colors.background + } + + Column( + modifier = Modifier.background(backgroundColor).combinedClickable( + onClick = { + if (noteEvent !is ChannelMessageEvent) { + navController.navigate("Note/${note.idHex}"){ + launchSingleTop = true + } + } else { + note.channel()?.let { + navController.navigate("Channel/${it.idHex}") + } + } + }, + onLongClick = { popupExpanded = true } + ) + ) { + Row(modifier = Modifier + .padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp) + ) { + + // Draws the like picture outside the boosted card. + if (!isInnerNote) { + Box(modifier = Modifier + .width(55.dp) + .padding(0.dp)) { + Icon( + imageVector = Icons.Default.MilitaryTech, + null, + modifier = Modifier.size(25.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colors.primary + ) + } + } + + Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { + Text( + stringResource(R.string.new_badge_award_notif), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 5.dp) + ) + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + baseNote = it, + routeForLastRead = null, + isBoostedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + } + + NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } + } + } + } +} \ No newline at end of file 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 d55a57208..c96ff6212 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 @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -26,6 +27,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -38,6 +40,8 @@ import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -106,6 +110,8 @@ fun NoteCompose( ) } else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) { ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) + } else if (noteEvent is BadgeDefinitionEvent) { + BadgeDisplay(baseNote = note) } else { var isNew by remember { mutableStateOf(false) } @@ -354,6 +360,39 @@ fun NoteCompose( ReactionsRow(note, accountViewModel) + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } else if (noteEvent is BadgeAwardEvent && !note.replyTo.isNullOrEmpty()) { + Text(text = stringResource(R.string.award_granted_to)) + + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + note.mentions?.forEach { + UserPicture( + user = it, + navController = navController, + userAccount = account.userProfile(), + size = 35.dp + ) + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = false, + isQuotedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + } + + ReactionsRow(note, accountViewModel) + Divider( modifier = Modifier.padding(top = 10.dp), thickness = 0.25.dp @@ -393,6 +432,61 @@ fun NoteCompose( } } +@Composable +fun BadgeDisplay(baseNote: Note) { + val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return + + Row( + modifier = Modifier + .padding(10.dp) + .clip(shape = CutCornerShape(20, 20, 0, 0)) + .border( + 5.dp, + MaterialTheme.colors.primary.copy(alpha = 0.32f), + CutCornerShape(20) + ) + ) { + Column { + badgeData.image()?.let { + AsyncImage( + model = it, + contentDescription = stringResource( + R.string.badge_award_image_for, + it + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } + + badgeData.name()?.let { + Text( + text = it, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + ) + } + + badgeData.description()?.let { + Text( + text = it, + style = MaterialTheme.typography.caption, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent) { Row( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 669bc0dad..77080f647 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -8,6 +8,14 @@ abstract class Card() { abstract fun id(): String } +class BadgeCard(val note: Note): Card() { + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } + + override fun id() = note.idHex +} + class NoteCard(val note: Note): Card() { override fun createdAt(): Long { return note.createdAt() ?: 0 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index 5b3d2e432..cb8a707ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.BoostSetCompose import com.vitorpamplona.amethyst.ui.note.LikeSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose @@ -127,6 +128,12 @@ private fun FeedLoaded( navController = navController, routeForLastRead = routeForLastRead ) + is BadgeCard -> BadgeCompose( + item, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 4711e5582..b3eb028ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent @@ -106,7 +107,12 @@ open class CardFeedViewModel(val dataSource: FeedFilter): ViewModel() { ) } - val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { NoteCard(it) } + val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { + if (it.event is BadgeAwardEvent) + BadgeCard(it) + else + NoteCard(it) + } return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed() } 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 e0c7b07b2..0a9cacc22 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -58,7 +59,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.ui.note.BadgeDisplay @Composable fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { @@ -173,7 +176,7 @@ fun Modifier.drawReplyLevel(level: Int, color: Color, selected: Color): Modifier repeat(level) { this.drawLine( - if (it == level-1) selected else color, + if (it == level - 1) selected else color, Offset(padding + it * levelWidth, 0f), Offset(padding + it * levelWidth, size.height), strokeWidth = strokeWidth @@ -267,7 +270,10 @@ fun NoteMaster(baseNote: Note, } } - if (noteEvent is LongTextNoteEvent) { + if (noteEvent is BadgeDefinitionEvent) { + Spacer(modifier = Modifier.padding(top=10.dp)) + BadgeDisplay(baseNote = note) + } else if (noteEvent is LongTextNoteEvent) { Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) { Column { noteEvent.image()?.let { 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 31b29defc..75b24de11 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 @@ -17,8 +17,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -34,6 +36,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -41,14 +44,20 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import coil.compose.AsyncImage import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.pagerTabIndicatorOffset import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.RoboHashCache 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.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy @@ -62,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.FeedView @@ -356,7 +366,7 @@ private fun ProfileHeader( } } - DrawAdditionalInfo(baseUser, account) + DrawAdditionalInfo(baseUser, account, navController) Divider(modifier = Modifier.padding(top = 6.dp)) } @@ -367,11 +377,15 @@ private fun ProfileHeader( } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun DrawAdditionalInfo(baseUser: User, account: Account) { +private fun DrawAdditionalInfo(baseUser: User, account: Account, navController: NavController) { val userState by baseUser.live().metadata.observeAsState() val user = userState?.user ?: return + val userBadgeState by baseUser.live().badges.observeAsState() + val userBadge = userBadgeState?.user ?: return + val uri = LocalUriHandler.current Row(verticalAlignment = Alignment.Bottom) { @@ -445,6 +459,24 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) { } } + userBadge.acceptedBadges?.let { note -> + (note.event as? BadgeProfilesEvent)?.let { event -> + FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + event.badgeAwardEvents().forEach { badgeAwardEvent -> + val baseNote = LocalCache.notes[badgeAwardEvent] + if (baseNote != null) { + val badgeAwardState by baseNote.live().metadata.observeAsState() + val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull() + + if (baseBadgeDefinition != null) { + BadgeThumb(baseBadgeDefinition, navController, 50.dp) + } + } + } + } + } + } + user.info?.about?.let { Text( it, @@ -454,6 +486,69 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) { } } +@Composable +fun BadgeThumb( + note: Note, + navController: NavController, + size: Dp, + pictureModifier: Modifier = Modifier +) { + BadgeThumb(note, size, pictureModifier) { + navController.navigate("Note/${it.idHex}") + } +} + + +@Composable +fun BadgeThumb( + baseNote: Note, + size: Dp, + pictureModifier: Modifier = Modifier, + onClick: ((Note) -> Unit)? = null +) { + val noteState by baseNote.live().metadata.observeAsState() + val note = noteState?.note ?: return + + val event = (note.event as? BadgeDefinitionEvent) + val image = event?.thumb() ?: event?.image() + + val ctx = LocalContext.current.applicationContext + + Box( + Modifier + .width(size) + .height(size)) { + if (image == null) { + Image( + painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")), + contentDescription = stringResource(R.string.unknown_author), + modifier = pictureModifier + .fillMaxSize(1f) + .background(MaterialTheme.colors.background) + ) + } else { + AsyncImage( + model = image, + contentDescription = stringResource(id = R.string.profile_image), + placeholder = BitmapPainter(RoboHashCache.get(ctx, note.idHex)), + fallback = BitmapPainter(RoboHashCache.get(ctx, note.idHex)), + error = BitmapPainter(RoboHashCache.get(ctx, note.idHex)), + modifier = pictureModifier + .fillMaxSize(1f) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .run { + if (onClick != null) + this.clickable(onClick = { onClick(note) } ) + else + this + } + + ) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun DrawBanner(baseUser: User) { @@ -471,7 +566,8 @@ private fun DrawBanner(baseUser: User) { contentScale = ContentScale.FillWidth, modifier = Modifier .fillMaxWidth() - .height(125.dp).combinedClickable( + .height(125.dp) + .combinedClickable( onClick = {}, onLongClick = { clipboardManager.setText(AnnotatedString(banner)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f3e8b53d..019803de8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,4 +187,9 @@ Secret key (nsec) copied to clipboard Copy my secret key Authentication failed + + "Created by %1$s" + "Badge award image for %1$s" + You Received a new Badge Award + Badge award granted to \ No newline at end of file