Bookmarks

This commit is contained in:
Vitor Pamplona 2023-03-20 18:16:07 -04:00
parent fc37789727
commit 2fb673acf0
31 changed files with 701 additions and 62 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -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()

View File

@ -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"))

View File

@ -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<Note>()
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()
}
}

View File

@ -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 }
}
}

View File

@ -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)

View File

@ -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 }
}
}

View File

@ -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<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:

View File

@ -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<List<String>>,
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<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.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<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
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<List<String>>()
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())
}
}
}

View File

@ -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())
}

View File

@ -23,15 +23,6 @@ class LnZapEvent(
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun taggedAddresses(): List<ATag> = 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
}

View File

@ -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

View File

@ -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<List<String>>,
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<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.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<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): MuteListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
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<List<String>>()
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())
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
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()
}
}

View File

@ -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<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
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()
}
}

View File

@ -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<Note>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.users[userId]
}
override fun feed(): List<Note> {
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()
}
}

View File

@ -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 = {

View File

@ -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,

View File

@ -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,

View File

@ -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))
}

View File

@ -99,8 +99,6 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
// val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val multiCards = allBaseNotes.map {
MultiSetCard(

View File

@ -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<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()

View File

@ -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)
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -91,6 +91,9 @@ fun ChannelScreen(
if (account != null && channelId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) }
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
val channel = channelState?.channel ?: return

View File

@ -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

View File

@ -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()

View File

@ -247,4 +247,14 @@
<string name="report_dialog_post_report_btn">Post Report</string>
<string name="report_dialog_title">Block and Report</string>
<string name="block_only">Block</string>
<string name="bookmarks">Bookmarks</string>
<string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public Bookmarks</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
</resources>