Support for Following Hashtags

This commit is contained in:
Vitor Pamplona
2023-03-16 14:20:30 -04:00
parent 811c373e4c
commit 888b6daa2a
10 changed files with 148 additions and 16 deletions
@@ -91,10 +91,12 @@ class Account(
val contactList = userProfile().latestContactList val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList() val follows = contactList?.follows() ?: emptyList()
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) { if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create( val event = ContactListEvent.create(
follows, follows,
followsTags,
relays, relays,
loggedIn.privKey!! loggedIn.privKey!!
) )
@@ -102,7 +104,7 @@ class Account(
Client.send(event) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
} else { } else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!) val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list. // Keep this local to avoid erasing a good contact list.
// Client.send(event) // Client.send(event)
@@ -246,11 +248,13 @@ class Account(
if (!isWriteable()) return if (!isWriteable()) return
val contactList = userProfile().latestContactList val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList() val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) { val event = if (contactList != null) {
ContactListEvent.create( ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)), followingUsers.plus(Contact(user.pubkeyHex, null)),
followingTags,
contactList.relays(), contactList.relays(),
loggedIn.privKey!! loggedIn.privKey!!
) )
@@ -258,6 +262,35 @@ class Account(
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create( ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)), listOf(Contact(user.pubkeyHex, null)),
followingTags,
relays,
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
relays, relays,
loggedIn.privKey!! loggedIn.privKey!!
) )
@@ -271,11 +304,33 @@ class Account(
if (!isWriteable()) return if (!isWriteable()) return
val contactList = userProfile().latestContactList val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList() val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) { if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create( val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex }, followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun unfollow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers,
followingTags.filter { it != tag },
contactList.relays(), contactList.relays(),
loggedIn.privKey!! loggedIn.privKey!!
) )
@@ -503,7 +558,11 @@ class Account(
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> { fun followingKeySet(): Set<HexKey> {
return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet() return userProfile().cachedFollowingKeySet() ?: emptySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
} }
fun isAcceptable(user: User): Boolean { fun isAcceptable(user: User): Boolean {
@@ -249,6 +249,12 @@ class User(val pubkeyHex: String) {
} ?: false } ?: false
} }
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
}
fun isFollowingCached(user: User): Boolean { fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let { return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it return user.pubkeyHex in it
@@ -267,6 +273,10 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet ?: emptySet() return latestContactList?.verifiedFollowKeySet ?: emptySet()
} }
fun cachedFollowingTagSet(): Set<HexKey> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowCount(): Int? { fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size return latestContactList?.verifiedFollowKeySet?.size
} }
@@ -58,9 +58,28 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
) )
} }
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val followAccountChannel = requestNewChannel() val followAccountChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null } followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
} }
} }
@@ -33,11 +33,16 @@ class ContactListEvent(
}.toSet() }.toSet()
} }
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy { val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey verifiedFollowKeySet + pubKey
} }
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull { fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try { try {
@@ -48,6 +53,10 @@ class ContactListEvent(
} }
} }
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
it.getOrNull(2)
}
fun relays(): Map<String, ReadWrite>? = try { fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) { if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite> gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
@@ -62,7 +71,7 @@ class ContactListEvent(
companion object { companion object {
const val kind = 3 const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent { fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null) { val content = if (relayUse != null) {
gson.toJson(relayUse) gson.toJson(relayUse)
} else { } else {
@@ -75,7 +84,10 @@ class ContactListEvent(
} else { } else {
listOf("p", it.pubKeyHex) listOf("p", it.pubKeyHex)
} }
} + followTags.map {
listOf("t", it)
} }
val id = generateId(pubKey, createdAt, kind, tags, content) val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey) val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -44,6 +44,8 @@ open class Event(
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex } override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) } override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
/** /**
* Checks if the ID is correct and then if the pubKey's secret key signed the event. * Checks if the ID is correct and then if the pubKey's secret key signed the event.
@@ -26,4 +26,6 @@ interface EventInterface {
fun isTaggedUser(loggedInUser: String): Boolean fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedHash(hashtag: String): Boolean fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
} }
@@ -12,11 +12,12 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val user = account.userProfile() val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet() val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values return LocalCache.notes.values
.filter { .filter {
(it.event is TextNoteEvent || it.event is RepostEvent) && (it.event is TextNoteEvent || it.event is RepostEvent) &&
it.author?.pubkeyHex in followingKeySet && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread() !it.isNewThread()
@@ -13,11 +13,12 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val user = account.userProfile() val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet() val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values val notes = LocalCache.notes.values
.filter { it -> .filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author?.pubkeyHex in followingKeySet && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread() it.isNewThread()
@@ -26,7 +27,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values val longFormNotes = LocalCache.addressables.values
.filter { it -> .filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author?.pubkeyHex in followingKeySet && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread() it.isNewThread()
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@@ -268,6 +269,15 @@ fun NoteCompose(
) )
} }
val firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
}
Text( Text(
timeAgo(note.createdAt(), context = context), timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -13,6 +13,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -22,10 +23,13 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable @Composable
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) { fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) {
@@ -70,7 +74,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navControlle
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
HashtagHeader(tag) HashtagHeader(tag, account)
FeedView(feedViewModel, accountViewModel, navController, null) FeedView(feedViewModel, accountViewModel, navController, null)
} }
} }
@@ -78,7 +82,12 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navControlle
} }
@Composable @Composable
fun HashtagHeader(tag: String) { fun HashtagHeader(tag: String, account: Account) {
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val coroutineScope = rememberCoroutineScope()
Column() { Column() {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -94,8 +103,15 @@ fun HashtagHeader(tag: String) {
) { ) {
Text( Text(
"#$tag", "#$tag",
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
) )
if (userFollows.isFollowingHashtag(tag)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(tag) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } }
}
} }
} }
} }