diff --git a/amethyst.png b/amethyst.png index b869584e3..dd74452c0 100644 Binary files a/amethyst.png and b/amethyst.png differ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1d5bed891..b3468c145 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.model.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -415,6 +416,116 @@ class Account( joinChannel(event.id) } + fun addPrivateBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun addPublicBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun removePrivateBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun removePublicBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.minus(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!) + ?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!) + ?.contains(note.idHex) == true + } + } + + fun isInPublicBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true + } + } + fun joinChannel(idHex: String) { followingChannels = followingChannels + idHex live.invalidateData() 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 5d13f91ca..f271b31f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -8,6 +8,7 @@ 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.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -157,6 +158,18 @@ object LocalCache { } } + fun consume(event: BookmarkListEvent) { + val user = getOrCreateUser(event.pubKey) + if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { + if (event.dTag() == "bookmark") { + user.updateBookmark(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)}") + } + } + fun formattedDateTime(timestamp: Long): String { return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) 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 938810f21..6317fa8ed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.model.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.MetadataEvent @@ -28,6 +29,7 @@ class User(val pubkeyHex: String) { var info: UserMetadata? = null var latestContactList: ContactListEvent? = null + var latestBookmarkList: BookmarkListEvent? = null var notes = setOf() private set @@ -75,6 +77,13 @@ class User(val pubkeyHex: String) { return info?.picture } + fun updateBookmark(event: BookmarkListEvent) { + if (event.id == latestBookmarkList?.id) return + + latestBookmarkList = event + liveSet?.bookmarks?.invalidateData() + } + fun updateContactList(event: ContactListEvent) { if (event.id == latestContactList?.id) return @@ -335,6 +344,7 @@ class UserLiveSet(u: User) { val metadata: UserLiveData = UserLiveData(u) val zaps: UserLiveData = UserLiveData(u) val badges: UserLiveData = UserLiveData(u) + val bookmarks: UserLiveData = UserLiveData(u) fun isInUse(): Boolean { return follows.hasObservers() || @@ -344,7 +354,8 @@ class UserLiveSet(u: User) { relayInfo.hasObservers() || metadata.hasObservers() || zaps.hasObservers() || - badges.hasObservers() + badges.hasObservers() || + bookmarks.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 fac4279b9..749e6ec72 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -3,6 +3,7 @@ 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.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent @@ -51,6 +52,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { ) } + fun createAccountBookmarkListFilter(): TypedFilter { + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BookmarkListEvent.kind), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1 + ) + ) + } + fun createAccountReportsFilter(): TypedFilter { return TypedFilter( types = FeedType.values().toSet(), @@ -87,7 +99,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountContactListFilter(), createNotificationFilter(), createAccountReportsFilter(), - createAccountAcceptedAwardsFilter() + createAccountAcceptedAwardsFilter(), + createAccountBookmarkListFilter() ).ifEmpty { null } } } 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 9df9931af..2da6b9223 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -5,6 +5,7 @@ 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.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -67,6 +68,7 @@ abstract class NostrDataSource(val debugName: String) { is BadgeAwardEvent -> LocalCache.consume(event) is BadgeDefinitionEvent -> LocalCache.consume(event) is BadgeProfilesEvent -> LocalCache.consume(event) + is BookmarkListEvent -> 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/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 39c85a97b..d302dddfb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -4,6 +4,7 @@ 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.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent @@ -90,6 +91,17 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { ) } + fun createBookmarksFilter() = user?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(BookmarkListEvent.kind), + authors = listOf(it.pubkeyHex), + limit = 1 + ) + ) + } + fun createReceivedAwardsFilter() = user?.let { TypedFilter( types = FeedType.values().toSet(), @@ -111,7 +123,8 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { createFollowersFilter(), createUserReceivedZapsFilter(), createAcceptedAwardsFilter(), - createReceivedAwardsFilter() + createReceivedAwardsFilter(), + createBookmarksFilter() ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index ce6e4e255..4583e39d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -15,13 +15,6 @@ open class BaseTextNoteEvent( fun mentions() = taggedUsers() fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - fun findCitations(): Set { var citations = mutableSetOf() // Removes citations from replies: diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt new file mode 100644 index 000000000..822f12d5b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt @@ -0,0 +1,117 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.google.gson.reflect.TypeToken +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class BookmarkListEvent( + 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(), null) + + fun category() = dTag() + fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) } + + fun plainContent(privKey: ByteArray): String? { + return try { + val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + + return Utils.decrypt(content, sharedSecret) + } catch (e: Exception) { + Log.w("BookmarkList", "Error decrypting the message ${e.message}") + null + } + } + + @Transient + private var privateTagsCache: List>? = null + + fun privateTags(privKey: ByteArray): List>? { + if (privateTagsCache != null) { + return privateTagsCache + } + + privateTagsCache = try { + gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type) + } catch (e: Throwable) { + Log.w("BookmarkList", "Error parsing the JSON ${e.message}") + null + } + return privateTagsCache + } + + fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) } + fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) } + fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + + companion object { + const val kind = 30001 + + fun create( + name: String = "", + + events: List? = null, + users: List? = null, + addresses: List? = null, + + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): BookmarkListEvent { + val pubKey = Utils.pubkeyCreate(privateKey) + + val privTags = mutableListOf>() + privEvents?.forEach { + privTags.add(listOf("e", it)) + } + privUsers?.forEach { + privTags.add(listOf("p", it)) + } + privAddresses?.forEach { + privTags.add(listOf("a", it.toTag())) + } + val msg = gson.toJson(privTags) + + val content = Utils.encrypt( + msg, + privateKey, + pubKey + ) + + val tags = mutableListOf>() + tags.add(listOf("d", name)) + + events?.forEach { + tags.add(listOf("e", it)) + } + users?.forEach { + tags.add(listOf("p", it)) + } + addresses?.forEach { + tags.add(listOf("a", it.toTag())) + } + + val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey()) + } + } +} 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 4656d9724..4a4ef3826 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 @@ -38,6 +38,14 @@ open class Event( override fun toJson(): String = gson.toJson(this) fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } @@ -175,7 +183,7 @@ open class Event( 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) - + BookmarkListEvent.kind -> BookmarkListEvent(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) @@ -206,7 +214,15 @@ open class Event( tags, content ) + + // GSON decided to hardcode these replacements. + // They break Nostr's hash check. + // These lines revert their code. + // https://github.com/google/gson/issues/2295 val rawEventJson = gson.toJson(rawEvent) + .replace("\\u2028", "\u2028") + .replace("\\u2029", "\u2029") + return sha256.digest(rawEventJson.toByteArray()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index e30336284..ff42b7676 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -23,15 +23,6 @@ class LnZapEvent( .filter { it.firstOrNull() == "p" } .mapNotNull { it.getOrNull(1) } - override fun taggedAddresses(): List = tags - .filter { it.firstOrNull() == "a" } - .mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - override fun amount(): BigDecimal? { return amount } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 2464eda1b..8863b7af4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -15,12 +15,6 @@ class LnZapRequestEvent( ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } companion object { const val kind = 9734 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt new file mode 100644 index 000000000..cb1f8aef7 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt @@ -0,0 +1,110 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.google.gson.reflect.TypeToken +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class MuteListEvent( + 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(), null) + + fun plainContent(privKey: ByteArray): String? { + return try { + val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + + return Utils.decrypt(content, sharedSecret) + } catch (e: Exception) { + Log.w("BookmarkList", "Error decrypting the message ${e.message}") + null + } + } + + @Transient + private var privateTagsCache: List>? = null + + fun privateTags(privKey: ByteArray): List>? { + if (privateTagsCache != null) { + return privateTagsCache + } + + privateTagsCache = try { + gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type) + } catch (e: Throwable) { + Log.w("BookmarkList", "Error parsing the JSON ${e.message}") + null + } + return privateTagsCache + } + + fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) } + fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) } + fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + + companion object { + const val kind = 10000 + + fun create( + events: List? = null, + users: List? = null, + addresses: List? = null, + + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): MuteListEvent { + val pubKey = Utils.pubkeyCreate(privateKey) + + val privTags = mutableListOf>() + privEvents?.forEach { + privTags.add(listOf("e", it)) + } + privUsers?.forEach { + privTags.add(listOf("p", it)) + } + privAddresses?.forEach { + privTags.add(listOf("a", it.toTag())) + } + val msg = gson.toJson(privTags) + + val content = Utils.encrypt( + msg, + privateKey, + pubKey + ) + + val tags = mutableListOf>() + events?.forEach { + tags.add(listOf("e", it)) + } + users?.forEach { + tags.add(listOf("p", it)) + } + addresses?.forEach { + tags.add(listOf("a", it.toTag())) + } + + val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return MuteListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index 75c3eee5b..f89bb2977 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -16,12 +16,6 @@ class ReactionEvent( fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } companion object { const val kind = 7 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index 63b51c21b..42f33c3c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -47,13 +47,6 @@ class ReportEvent( ) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - companion object { const val kind = 1984 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt index ce13a6e5a..d5e0f1eb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt @@ -17,12 +17,6 @@ class RepostEvent( fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } fun containedPost() = try { fromJson(content, Client.lenient) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt new file mode 100644 index 000000000..c748f35ff --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt @@ -0,0 +1,25 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note + +object BookmarkPrivateFeedFilter : FeedFilter() { + lateinit var account: Account + + override fun feed(): List { + val privKey = account.loggedIn.privKey ?: return emptyList() + + val bookmarks = account.userProfile().latestBookmarkList + + val notes = bookmarks?.privateTaggedEvents(privKey) + ?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList() + + val addresses = bookmarks?.privateTaggedAddresses(privKey) + ?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() + + return notes.plus(addresses) + .sortedBy { it.createdAt() } + .reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt new file mode 100644 index 000000000..5d3ebbabf --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt @@ -0,0 +1,20 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note + +object BookmarkPublicFeedFilter : FeedFilter() { + lateinit var account: Account + + override fun feed(): List { + val bookmarks = account.userProfile().latestBookmarkList + + val notes = bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList() + val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() + + return notes.plus(addresses) + .sortedBy { it.createdAt() } + .reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt new file mode 100644 index 000000000..db26758a1 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User + +object UserProfileBookmarksFeedFilter : FeedFilter() { + lateinit var account: Account + var user: User? = null + + fun loadUserProfile(accountLoggedIn: Account, userId: String) { + account = accountLoggedIn + user = LocalCache.users[userId] + } + + override fun feed(): List { + val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull { + LocalCache.checkGetOrCreateNote(it) + }?.toSet() ?: emptySet() + + val addresses = user?.latestBookmarkList?.taggedAddresses()?.map { + LocalCache.getOrCreateAddressableNote(it) + }?.toSet() ?: emptySet() + + return (notes + addresses) + .filter { account.isAcceptable(it) } + .sortedBy { it.createdAt() } + .reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 321ad9cf2..a4565e4bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -16,11 +16,12 @@ import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen @@ -73,7 +74,8 @@ fun AppNavigation( composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) }) composable(Route.Notification.route, content = { NotificationScreen(accountViewModel, navController) }) - composable(Route.Filters.route, content = { FiltersScreen(accountViewModel, navController) }) + composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, navController) }) + composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, navController) }) Route.Profile.let { route -> composable(route.route, route.arguments, content = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 97a2d883d..defddc0c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -232,21 +232,26 @@ fun ListContent( scaffoldState = scaffoldState, route = "User/${accountUser.pubkeyHex}" ) - } - Divider(thickness = 0.25.dp) + NavigationRow( + title = stringResource(R.string.bookmarks), + icon = Route.Bookmarks.icon, + tint = MaterialTheme.colors.onBackground, + navController = navController, + scaffoldState = scaffoldState, + route = Route.Bookmarks.route + ) + } NavigationRow( title = stringResource(R.string.security_filters), - icon = Route.Filters.icon, + icon = Route.BlockedUsers.icon, tint = MaterialTheme.colors.onBackground, navController = navController, scaffoldState = scaffoldState, - route = Route.Filters.route + route = Route.BlockedUsers.route ) - Divider(thickness = 0.25.dp) - IconRow( title = stringResource(R.string.backup_keys), icon = R.drawable.ic_key, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 2fc3bdd3d..2062c2005 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -48,11 +48,16 @@ sealed class Route( hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) } ) - object Filters : Route( - route = "Filters", + object BlockedUsers : Route( + route = "BlockedUsers", icon = R.drawable.ic_security ) + object Bookmarks : Route( + route = "Bookmarks", + icon = R.drawable.ic_bookmarks + ) + object Profile : Route( route = "User/{id}", icon = R.drawable.ic_profile, 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 2cbc47401..5c1df2b66 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 @@ -870,6 +870,25 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, Text(stringResource(R.string.quick_action_share)) } Divider() + if (accountViewModel.isInPrivateBookmarks(note)) { + DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) { + Text(stringResource(R.string.remove_from_private_bookmarks)) + } + } else { + DropdownMenuItem(onClick = { accountViewModel.addPrivateBookmark(note); onDismiss() }) { + Text(stringResource(R.string.add_to_private_bookmarks)) + } + } + if (accountViewModel.isInPublicBookmarks(note)) { + DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) { + Text(stringResource(R.string.remove_from_public_bookmarks)) + } + } else { + DropdownMenuItem(onClick = { accountViewModel.addPublicBookmark(note); onDismiss() }) { + Text(stringResource(R.string.add_to_public_bookmarks)) + } + } + Divider() DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) { Text(stringResource(R.string.broadcast)) } 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 3060f307a..fa80bee9a 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 @@ -99,8 +99,6 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } } - // val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) } - val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys val multiCards = allBaseNotes.map { MultiSetCard( 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 053ee1ef1..fd0e45cd7 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 @@ -5,6 +5,8 @@ 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.ui.dal.BookmarkPrivateFeedFilter +import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter @@ -15,6 +17,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.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter @@ -38,11 +41,15 @@ class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter) class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter) +class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter) class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter) class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter) class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter) class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter) +class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter) +class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter) + abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(FeedState.Loading) val feedContent = _feedContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 460c5dd4b..3eae24257 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -85,6 +85,30 @@ class AccountViewModel(private val account: Account) : ViewModel() { account.boost(note) } + fun addPrivateBookmark(note: Note) { + account.addPrivateBookmark(note) + } + + fun addPublicBookmark(note: Note) { + account.addPublicBookmark(note) + } + + fun removePrivateBookmark(note: Note) { + account.removePrivateBookmark(note) + } + + fun removePublicBookmark(note: Note) { + account.removePublicBookmark(note) + } + + fun isInPrivateBookmarks(note: Note): Boolean { + return account.isInPrivateBookmarks(note) + } + + fun isInPublicBookmarks(note: Note): Boolean { + return account.isInPublicBookmarks(note) + } + fun broadcast(note: Note) { account.broadcast(note) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt new file mode 100644 index 000000000..df3289bca --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -0,0 +1,92 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +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.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +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.ui.dal.BookmarkPrivateFeedFilter +import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedView +import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account + + if (account != null) { + BookmarkPublicFeedFilter.account = account + BookmarkPrivateFeedFilter.account = account + + val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = viewModel() + val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = viewModel() + + val userState by account.userProfile().live().bookmarks.observeAsState() + + LaunchedEffect(userState) { + publicFeedViewModel.invalidateData() + privateFeedViewModel.invalidateData() + } + + Column(Modifier.fillMaxHeight()) { + Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) { + val pagerState = rememberPagerState() + val coroutineScope = rememberCoroutineScope() + + TabRow( + backgroundColor = MaterialTheme.colors.background, + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), + color = MaterialTheme.colors.primary + ) + } + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { + Text(text = stringResource(R.string.private_bookmarks)) + } + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { + Text(text = stringResource(R.string.public_bookmarks)) + } + ) + } + HorizontalPager(count = 2, state = pagerState) { + when (pagerState.currentPage) { + 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) + 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index fcc0007db..8520bbab9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -91,6 +91,9 @@ fun ChannelScreen( if (account != null && channelId != null) { val replyTo = remember { mutableStateOf(null) } + ChannelFeedFilter.loadMessagesBetween(account, channelId) + NostrChannelDataSource.loadMessagesBetween(account, channelId) + val channelState by NostrChannelDataSource.channel!!.live.observeAsState() val channel = channelState?.channel ?: return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt similarity index 97% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 8f9484d36..436c1b0b0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalPagerApi::class) @Composable -fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavController) { +fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account 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 4c522668a..1e1d59578 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 @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter @@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.FeedView import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView +import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewModel @@ -104,6 +106,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro UserProfileFollowsFeedFilter.loadUserProfile(account, userId) UserProfileZapsFeedFilter.loadUserProfile(userId) UserProfileReportsFeedFilter.loadUserProfile(userId) + UserProfileBookmarksFeedFilter.loadUserProfile(account, userId) NostrUserProfileDataSource.loadUserProfile(userId) @@ -112,7 +115,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro DisposableEffect(accountViewModel) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - println("Profile Start") + println("Profidle Start") NostrUserProfileDataSource.loadUserProfile(userId) NostrUserProfileDataSource.start() } @@ -225,6 +228,14 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}") }, + { + val userState by baseUser.live().bookmarks.observeAsState() + val bookmarkList = userState?.user?.latestBookmarkList + val userBookmarks = + (bookmarkList?.taggedEvents()?.count() ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0) + + Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") + }, { val userState by baseUser.live().reports.observeAsState() val userReports = userState?.user?.reports?.values?.flatten()?.count() @@ -251,7 +262,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro } } HorizontalPager( - count = 7, + count = 8, state = pagerState, modifier = with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) @@ -263,8 +274,9 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro 2 -> TabFollows(baseUser, accountViewModel, navController) 3 -> TabFollowers(baseUser, accountViewModel, navController) 4 -> TabReceivedZaps(baseUser, accountViewModel, navController) - 5 -> TabReports(baseUser, accountViewModel, navController) - 6 -> TabRelays(baseUser, accountViewModel) + 5 -> TabBookmarks(baseUser, accountViewModel, navController) + 6 -> TabReports(baseUser, accountViewModel, navController) + 7 -> TabRelays(baseUser, accountViewModel) } } } @@ -694,6 +706,27 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav } } +@Composable +fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val userState by baseUser.live().bookmarks.observeAsState() + if (accountState != null) { + val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel() + + LaunchedEffect(userState) { + feedViewModel.refresh() + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(feedViewModel, accountViewModel, navController, null) + } + } + } +} + @Composable fun TabFollows(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { val feedViewModel: NostrUserProfileFollowsUserFeedViewModel = viewModel() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01cc48ba6..bff2399f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -247,4 +247,14 @@ Post Report Block and Report Block + + Bookmarks + Private Bookmarks + Public Bookmarks + + Add to Private Bookmarks + Add to Public Bookmarks + + Remove from Private Bookmarks + Remove from Public Bookmarks