kopia lustrzana https://github.com/vitorpamplona/amethyst
Support for Following Hashtags
rodzic
811c373e4c
commit
888b6daa2a
|
@ -91,10 +91,12 @@ class Account(
|
|||
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = ContactListEvent.create(
|
||||
follows,
|
||||
followsTags,
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
@ -102,7 +104,7 @@ class Account(
|
|||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
} 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.
|
||||
// Client.send(event)
|
||||
|
@ -246,11 +248,13 @@ class Account(
|
|||
if (!isWriteable()) return
|
||||
|
||||
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(
|
||||
follows.plus(Contact(user.pubkeyHex, null)),
|
||||
followingUsers.plus(Contact(user.pubkeyHex, null)),
|
||||
followingTags,
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
@ -258,6 +262,35 @@ class Account(
|
|||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(
|
||||
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,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
@ -271,11 +304,33 @@ class Account(
|
|||
if (!isWriteable()) return
|
||||
|
||||
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(
|
||||
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(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
@ -503,7 +558,11 @@ class Account(
|
|||
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
|
||||
|
||||
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 {
|
||||
|
|
|
@ -249,6 +249,12 @@ class User(val pubkeyHex: String) {
|
|||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
|
||||
return tag in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(user: User): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return user.pubkeyHex in it
|
||||
|
@ -267,6 +273,10 @@ class User(val pubkeyHex: String) {
|
|||
return latestContactList?.verifiedFollowKeySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingTagSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowCount(): Int? {
|
||||
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()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,11 +33,16 @@ class ContactListEvent(
|
|||
}.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowTagSet: Set<String> by lazy {
|
||||
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
|
@ -48,6 +53,10 @@ class ContactListEvent(
|
|||
}
|
||||
}
|
||||
|
||||
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
|
||||
it.getOrNull(2)
|
||||
}
|
||||
|
||||
fun relays(): Map<String, ReadWrite>? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
|
||||
|
@ -62,7 +71,7 @@ class ContactListEvent(
|
|||
companion object {
|
||||
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) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
|
@ -75,7 +84,10 @@ class ContactListEvent(
|
|||
} else {
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
} + followTags.map {
|
||||
listOf("t", it)
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
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 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.
|
||||
|
|
|
@ -26,4 +26,6 @@ interface EventInterface {
|
|||
fun isTaggedUser(loggedInUser: 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> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
|
||||
return LocalCache.notes.values
|
||||
.filter {
|
||||
(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
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
!it.isNewThread()
|
||||
|
|
|
@ -13,11 +13,12 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
|||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
|
||||
val notes = LocalCache.notes.values
|
||||
.filter { it ->
|
||||
(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
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
it.isNewThread()
|
||||
|
@ -26,7 +27,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
|||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it ->
|
||||
(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
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
it.isNewThread()
|
||||
|
|
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
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(
|
||||
timeAgo(note.createdAt(), context = context),
|
||||
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.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
|
@ -22,10 +23,13 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
|
@ -70,7 +74,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navControlle
|
|||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
HashtagHeader(tag)
|
||||
HashtagHeader(tag, account)
|
||||
FeedView(feedViewModel, accountViewModel, navController, null)
|
||||
}
|
||||
}
|
||||
|
@ -78,7 +82,12 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navControlle
|
|||
}
|
||||
|
||||
@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(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
@ -94,8 +103,15 @@ fun HashtagHeader(tag: String) {
|
|||
) {
|
||||
Text(
|
||||
"#$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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue