Adds support for Private and Public Follow Lists

This commit is contained in:
Vitor Pamplona 2023-05-04 20:08:36 -04:00
parent 76283463b0
commit 8c2e89197e
21 changed files with 517 additions and 159 deletions

View File

@ -46,6 +46,8 @@ private object PrefKeys {
const val ZAP_AMOUNTS = "zapAmounts" const val ZAP_AMOUNTS = "zapAmounts"
const val DEFAULT_ZAPTYPE = "defaultZapType" const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@ -197,6 +199,8 @@ object LocalPreferences {
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@ -217,6 +221,8 @@ object LocalPreferences {
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null)
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null)
val zapAmountChoices = gson.fromJson( val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"), getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@ -278,6 +284,8 @@ object LocalPreferences {
zapAmountChoices, zapAmountChoices,
defaultZapType, defaultZapType,
defaultFileServer, defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
zapPaymentRequestServer, zapPaymentRequestServer,
hideDeleteRequestDialog, hideDeleteRequestDialog,
hideBlockAlertDialog, hideBlockAlertDialog,

View File

@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.Constants
@ -33,6 +34,7 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
// Notification Elements // Notification Elements
NostrHomeDataSource.start() NostrHomeDataSource.start()
@ -61,6 +63,7 @@ object ServiceManager {
NostrSingleUserDataSource.stop() NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop() NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop() NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
Client.disconnect() Client.disconnect()
} }

View File

@ -38,6 +38,9 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList return codedList
} }
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class Account( class Account(
val loggedIn: Persona, val loggedIn: Persona,
@ -50,6 +53,8 @@ class Account(
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L), var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var defaultHomeFollowList: String? = null,
var defaultStoriesFollowList: String? = null,
var zapPaymentRequest: Nip47URI? = null, var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false, var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
@ -729,6 +734,18 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun changeDefaultHomeFollowList(name: String?) {
defaultHomeFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeDefaultStoriesFollowList(name: String?) {
defaultStoriesFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) { fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts zapAmountChoices = newAmounts
live.invalidateData() live.invalidateData()
@ -741,6 +758,37 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun selectedUsersFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
val privKey = loggedIn.privKey
return if (listName != null) {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
val list = LocalCache.addressables[aTag]
if (list != null) {
val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
val privateHexList = privKey?.let {
(list.event as? PeopleListEvent)?.privateTaggedUsers(it)
} ?: emptySet()
(publicHexList + privateHexList).toSet()
} else {
emptySet()
}
} else {
emptySet()
}
}
fun selectedTagsFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
return emptySet()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return if (!isWriteable()) return

View File

@ -159,6 +159,20 @@ object LocalCache {
} }
} }
fun consume(event: PeopleListEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun formattedDateTime(timestamp: Long): String { fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))

View File

@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind), kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), authors = listOf(account.userProfile().pubkeyHex),
limit = 1 limit = 100
) )
) )
} }

View File

@ -90,6 +90,7 @@ abstract class NostrDataSource(val debugName: String) {
is LongTextNoteEvent -> LocalCache.consume(event, relay) is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event) is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay) is PrivateDmEvent -> LocalCache.consume(event, relay)
is PeopleListEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event) is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event) is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay) is ReportEvent -> LocalCache.consume(event, relay)

View File

@ -45,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
} }
fun createFollowAccountsFilter(): TypedFilter { fun createFollowAccountsFilter(): TypedFilter {
val follows = account.followingKeySet() val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followKeys = follows.map { val followKeys = follows.map {
it.substring(0, 6) it.substring(0, 6)
@ -65,7 +65,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
} }
fun createFollowTagsFilter(): TypedFilter? { fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet() val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
if (hashToLoad.isEmpty()) return null if (hashToLoad.isEmpty()) return null

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
@ -7,17 +8,47 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") { object NostrVideoDataSource : NostrDataSource("VideoFeed") {
fun createGlobalFilter() = TypedFilter( lateinit var account: Account
types = setOf(FeedType.GLOBAL),
filter = JsonFilter( fun createContextualFilter(): TypedFilter? {
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
limit = 200
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
) )
) }
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val videoFeedChannel = requestNewChannel() val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
} }
} }

View File

@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service.model 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.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils import nostr.postr.Utils
import java.util.Date import java.util.Date
@ -15,50 +12,7 @@ class BookmarkListEvent(
tags: List<List<String>>, tags: List<List<String>>,
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : GeneralListEvent(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 { companion object {
const val kind = 30001 const val kind = 30001
@ -77,24 +31,7 @@ class BookmarkListEvent(
createdAt: Long = Date().time / 1000 createdAt: Long = Date().time / 1000
): BookmarkListEvent { ): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey) val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
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>>() val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name)) tags.add(listOf("d", name))

View File

@ -238,6 +238,7 @@ open class Event(
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)

View File

@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
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() = taggedEvents()
fun bookmarkedPeople() = taggedUsers()
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "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("GeneralList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[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 {
fun createPrivateTags(
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
pubKey: ByteArray
): String {
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)
return Utils.encrypt(
msg,
privateKey,
pubKey
)
}
}
}

View File

@ -0,0 +1,54 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class PeopleListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30000
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
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, 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 PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -33,7 +33,14 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@Composable @Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { fun TextSpinner(
label: String,
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) } var optionsShowing by remember { mutableStateOf(false) }

View File

@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
} }
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> { private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile() val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = user.cachedFollowingTagSet()
return collection return collection
.asSequence() .asSequence()

View File

@ -24,9 +24,8 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
} }
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> { private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile() val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = user.cachedFollowingTagSet()
return collection return collection
.asSequence() .asSequence()

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
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object PeopleListFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
val lists = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is PeopleListEvent)
}
.toSet()
return lists
.sortedBy { it.createdAt() }
.reversed()
}
}

View File

@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
@ -20,17 +21,17 @@ object VideoFeedFilter : AdditiveFeedFilter<Note>() {
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> { private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000 val now = System.currentTimeMillis() / 1000
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
return collection return collection
.asSequence() .asSequence()
.filter { .filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent }
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) }
}
.filter { account.isAcceptable(it) } .filter { account.isAcceptable(it) }
.filter { .filter { it.createdAt()!! <= now }
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.toSet() .toSet()
} }

View File

@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log import android.util.Log
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -23,6 +24,8 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -41,6 +44,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.Coil import coil.Coil
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@ -55,6 +61,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
@ -62,33 +69,58 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable @Composable
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)) { when (currentRoute(navController)?.substringBefore("?")) {
// Route.Profile.route -> TopBarWithBackButton(navController) // Route.Profile.route -> TopBarWithBackButton(navController)
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel) else -> MainTopBar(scaffoldState, accountViewModel)
} }
} }
@Composable
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultStoriesFollowList, true) { listName ->
account.changeDefaultStoriesFollowList(listName)
}
}
}
@Composable
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultHomeFollowList, false) { listName ->
account.changeDefaultHomeFollowList(listName)
}
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) {
AmethystIcon()
}
}
@OptIn(coil.annotation.ExperimentalCoilApi::class) @OptIn(coil.annotation.ExperimentalCoilApi::class)
@Composable @Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState() val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState() val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var wantsToEditRelays by remember { var wantsToEditRelays by remember {
mutableStateOf(false) mutableStateOf(false)
} }
@ -115,48 +147,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
IconButton( content(account)
onClick = {
Client.allSubscriptions().map {
"$it ${
Client.getSubscriptionFilters(it)
.joinToString { it.filter.toJson() }
}"
}.forEach {
Log.d("STATE DUMP", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
} }
Column( Column(
@ -186,23 +177,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
} }
}, },
navigationIcon = { navigationIcon = {
IconButton( LoggedInUserPictureDrawer(account) {
onClick = { coroutineScope.launch {
coroutineScope.launch { scaffoldState.drawerState.open()
scaffoldState.drawerState.open() }
}
},
modifier = Modifier
) {
RobohashAsyncImageProxy(
robot = accountUser.pubkeyHex,
model = ResizeImage(accountUser.profilePicture(), 34.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
.height(34.dp)
.clip(shape = CircleShape)
)
} }
}, },
actions = { actions = {
@ -223,6 +201,107 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
} }
} }
@Composable
private fun LoggedInUserPictureDrawer(
account: Account,
onClick: () -> Unit
) {
val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
IconButton(
onClick = onClick,
modifier = Modifier
) {
RobohashAsyncImageProxy(
robot = accountUser.pubkeyHex,
model = ResizeImage(accountUser.profilePicture(), 34.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
.height(34.dp)
.clip(shape = CircleShape)
)
}
}
@Composable
fun FollowList(listName: String?, withGlobal: Boolean, onChange: (String?) -> Unit) {
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
var followLists by remember { mutableStateOf(defaultOptions) }
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
if (event != null && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag())
} else {
null
}
}.sortedBy { it.second }
}
}
SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS,
options = followNames.value,
onSelect = {
onChange(followLists.getOrNull(it)?.first)
}
)
}
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Text(currentText)
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable @Composable
fun TopBarWithBackButton(navController: NavHostController) { fun TopBarWithBackButton(navController: NavHostController) {
Column() { Column() {
@ -250,3 +329,51 @@ fun TopBarWithBackButton(navController: NavHostController) {
Divider(thickness = 0.25.dp) Divider(thickness = 0.25.dp)
} }
} }
@Composable
fun AmethystIcon() {
val context = LocalContext.current
IconButton(
onClick = {
Client.allSubscriptions().map {
"$it ${
Client.getSubscriptionFilters(it)
.joinToString { it.filter.toJson() }
}"
}.forEach {
Log.d("STATE DUMP", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
}

View File

@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect 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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -50,9 +51,11 @@ fun HomeScreen(
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val account = accountViewModel.accountLiveData.value?.account ?: return val account = accountViewModel.accountLiveData.value?.account ?: return
var wantsToAddNip47 by remember { mutableStateOf<String?>(nip47) } var wantsToAddNip47 by remember { mutableStateOf(nip47) }
LaunchedEffect(accountViewModel) { val accountState = account.live.observeAsState()
LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) {
HomeNewThreadFeedFilter.account = account HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters() NostrHomeDataSource.resetFilters()

View File

@ -105,10 +105,14 @@ fun VideoScreen(
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return val account = accountViewModel.accountLiveData.value?.account ?: return
val accountState = account.live.observeAsState()
NostrVideoDataSource.account = account
VideoFeedFilter.account = account VideoFeedFilter.account = account
LaunchedEffect(accountViewModel) { LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) {
VideoFeedFilter.account = account VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.resetFilters() NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData() videoFeedView.invalidateData()
} }
@ -118,6 +122,7 @@ fun VideoScreen(
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start") println("Video Start")
VideoFeedFilter.account = account VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.start() NostrVideoDataSource.start()
videoFeedView.invalidateData() videoFeedView.invalidateData()
} }

View File

@ -348,4 +348,10 @@
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string> <string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string> <string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
<string name="follow_list_selection">Follow List</string>
<string name="follow_list_kind3follows">All Follows</string>
<string name="follow_list_global">Global</string>
</resources> </resources>