From 8c2e89197ec1ffb177fc5450859868967315814a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 May 2023 20:08:36 -0400 Subject: [PATCH] Adds support for Private and Public Follow Lists --- .../amethyst/LocalPreferences.kt | 8 + .../vitorpamplona/amethyst/ServiceManager.kt | 3 + .../vitorpamplona/amethyst/model/Account.kt | 48 ++++ .../amethyst/model/LocalCache.kt | 14 + .../service/NostrAccountDataSource.kt | 4 +- .../amethyst/service/NostrDataSource.kt | 1 + .../amethyst/service/NostrHomeDataSource.kt | 4 +- .../amethyst/service/NostrVideoDataSource.kt | 45 ++- .../service/model/BookmarkListEvent.kt | 67 +---- .../amethyst/service/model/Event.kt | 1 + .../service/model/GeneralListEvent.kt | 89 ++++++ .../amethyst/service/model/PeopleListEvent.kt | 54 ++++ .../amethyst/ui/components/TextSpinner.kt | 9 +- .../ui/dal/HomeConversationsFeedFilter.kt | 5 +- .../ui/dal/HomeNewThreadFeedFilter.kt | 5 +- .../amethyst/ui/dal/PeopleListFeedFilter.kt | 25 ++ .../amethyst/ui/dal/VideoFeedFilter.kt | 15 +- .../amethyst/ui/navigation/AppTopBar.kt | 259 +++++++++++++----- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 7 +- .../ui/screen/loggedIn/VideoScreen.kt | 7 +- app/src/main/res/values/strings.xml | 6 + 21 files changed, 517 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 09e7bf954..cb945c8f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -46,6 +46,8 @@ private object PrefKeys { const val ZAP_AMOUNTS = "zapAmounts" const val DEFAULT_ZAPTYPE = "defaultZapType" 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 LATEST_CONTACT_LIST = "latestContactList" 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.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) 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.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -217,6 +221,8 @@ object LocalPreferences { val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() 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( getString(PrefKeys.ZAP_AMOUNTS, "[]"), @@ -278,6 +284,8 @@ object LocalPreferences { zapAmountChoices, defaultZapType, defaultFileServer, + defaultHomeFollowList, + defaultStoriesFollowList, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 1faebf214..480097a68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource 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.Constants @@ -33,6 +34,7 @@ object ServiceManager { NostrAccountDataSource.account = myAccount NostrHomeDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount + NostrVideoDataSource.account = myAccount // Notification Elements NostrHomeDataSource.start() @@ -61,6 +63,7 @@ object ServiceManager { NostrSingleUserDataSource.stop() NostrThreadDataSource.stop() NostrUserProfileDataSource.stop() + NostrVideoDataSource.stop() Client.disconnect() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7d5952379..5dad0402f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,6 +38,9 @@ fun getLanguagesSpokenByUser(): Set { return codedList } +val GLOBAL_FOLLOWS = " Global " +val KIND3_FOLLOWS = " All Follows " + @OptIn(DelicateCoroutinesApi::class) class Account( val loggedIn: Persona, @@ -50,6 +53,8 @@ class Account( var zapAmountChoices: List = listOf(500L, 1000L, 5000L), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, + var defaultHomeFollowList: String? = null, + var defaultStoriesFollowList: String? = null, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -729,6 +734,18 @@ class Account( 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) { zapAmountChoices = newAmounts live.invalidateData() @@ -741,6 +758,37 @@ class Account( saveable.invalidateData() } + fun selectedUsersFollowList(listName: String?): Set? { + 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? { + 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) { if (!isWriteable()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6dff6da3a..2ecfcf63e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 { return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 3025a742f..45bbb4f1b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { return TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(BookmarkListEvent.kind), + kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - limit = 1 + limit = 100 ) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 8335a7177..5de8ef2bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -90,6 +90,7 @@ abstract class NostrDataSource(val debugName: String) { is LongTextNoteEvent -> LocalCache.consume(event, relay) is MetadataEvent -> LocalCache.consume(event) is PrivateDmEvent -> LocalCache.consume(event, relay) + is PeopleListEvent -> LocalCache.consume(event) is ReactionEvent -> LocalCache.consume(event) is RecommendRelayEvent -> LocalCache.consume(event) is ReportEvent -> LocalCache.consume(event, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 5f855bc7c..a4b5e8232 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -45,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { } fun createFollowAccountsFilter(): TypedFilter { - val follows = account.followingKeySet() + val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() val followKeys = follows.map { it.substring(0, 6) @@ -65,7 +65,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { } fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.followingTagSet() + val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() if (hashToLoad.isEmpty()) return null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 5ad410390..6dd343e56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent 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 object NostrVideoDataSource : NostrDataSource("VideoFeed") { - fun createGlobalFilter() = TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), - limit = 200 + lateinit var account: Account + + fun createContextualFilter(): TypedFilter? { + val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList) + + 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() override fun updateChannelFilters() { - videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } + videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt index 822f12d5b..71f1ad7e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt @@ -1,9 +1,6 @@ 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 @@ -15,50 +12,7 @@ class BookmarkListEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) - - fun category() = dTag() - fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) } - - fun plainContent(privKey: ByteArray): String? { - return try { - val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) - - return Utils.decrypt(content, sharedSecret) - } catch (e: Exception) { - Log.w("BookmarkList", "Error decrypting the message ${e.message}") - null - } - } - - @Transient - private var privateTagsCache: List>? = null - - fun privateTags(privKey: ByteArray): List>? { - if (privateTagsCache != null) { - return privateTagsCache - } - - privateTagsCache = try { - gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type) - } catch (e: Throwable) { - Log.w("BookmarkList", "Error parsing the JSON ${e.message}") - null - } - return privateTagsCache - } - - fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) } - fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) } - fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - +) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { companion object { const val kind = 30001 @@ -77,24 +31,7 @@ class BookmarkListEvent( createdAt: Long = Date().time / 1000 ): BookmarkListEvent { val pubKey = Utils.pubkeyCreate(privateKey) - - val privTags = mutableListOf>() - privEvents?.forEach { - privTags.add(listOf("e", it)) - } - privUsers?.forEach { - privTags.add(listOf("p", it)) - } - privAddresses?.forEach { - privTags.add(listOf("a", it.toTag())) - } - val msg = gson.toJson(privTags) - - val content = Utils.encrypt( - msg, - privateKey, - pubKey - ) + val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey) val tags = mutableListOf>() tags.add(listOf("d", name)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 3ecf62c9f..0be651a7d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -238,6 +238,7 @@ open class Event( LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(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) PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt new file mode 100644 index 000000000..3183fe783 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -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>, + 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>? = null + + fun privateTags(privKey: ByteArray): List>? { + if (privateTagsCache != null) { + return privateTagsCache + } + + privateTagsCache = try { + gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type) + } catch (e: Throwable) { + Log.w("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? = null, + privUsers: List? = null, + privAddresses: List? = null, + + privateKey: ByteArray, + pubKey: ByteArray + ): String { + val privTags = mutableListOf>() + privEvents?.forEach { + privTags.add(listOf("e", it)) + } + privUsers?.forEach { + privTags.add(listOf("p", it)) + } + privAddresses?.forEach { + privTags.add(listOf("a", it.toTag())) + } + val msg = gson.toJson(privTags) + + return Utils.encrypt( + msg, + privateKey, + pubKey + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt new file mode 100644 index 000000000..583f534b5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt @@ -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>, + content: String, + sig: HexKey +) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { + companion object { + const val kind = 30000 + + fun create( + name: String = "", + + events: List? = null, + users: List? = null, + addresses: List? = null, + + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): PeopleListEvent { + val pubKey = Utils.pubkeyCreate(privateKey) + val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey) + + val tags = mutableListOf>() + tags.add(listOf("d", name)) + + events?.forEach { + tags.add(listOf("e", it)) + } + users?.forEach { + tags.add(listOf("p", it)) + } + addresses?.forEach { + tags.add(listOf("a", it.toTag())) + } + + val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index d55227fe2..40f40f839 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -33,7 +33,14 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @Composable -fun TextSpinner(label: String, placeholder: String, options: List, explainers: List? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { +fun TextSpinner( + label: String, + placeholder: String, + options: List, + explainers: List? = null, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } var optionsShowing by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 6026e2669..a5cda41b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() { } private fun innerApplyFilter(collection: Collection): Set { - val user = account.userProfile() - val followingKeySet = user.cachedFollowingKeySet() - val followingTagSet = user.cachedFollowingTagSet() + val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() return collection .asSequence() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index b5f62086e..fcd175020 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -24,9 +24,8 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { } private fun innerApplyFilter(collection: Collection): Set { - val user = account.userProfile() - val followingKeySet = user.cachedFollowingKeySet() - val followingTagSet = user.cachedFollowingTagSet() + val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() return collection .asSequence() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt new file mode 100644 index 000000000..11263ffc8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt @@ -0,0 +1,25 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.PeopleListEvent + +object PeopleListFeedFilter : FeedFilter() { + lateinit var account: Account + + override fun feed(): List { + 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() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 6ab0901cb..be17bf33a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* @@ -20,17 +21,17 @@ object VideoFeedFilter : AdditiveFeedFilter() { private fun innerApplyFilter(collection: Collection): Set { 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 .asSequence() - .filter { - it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent - } + .filter { 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 { - // 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 - } + .filter { it.createdAt()!! <= now } .toSet() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index c99cfe66a..91be7bf4f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation import android.util.Log import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.filled.ArrowBack import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -41,6 +44,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import coil.Coil 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.service.NostrAccountDataSource 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.NostrThreadDataSource 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.RelayPool 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.screen.RelayPoolViewModel 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.withContext @Composable fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { - when (currentRoute(navController)) { + when (currentRoute(navController)?.substringBefore("?")) { // Route.Profile.route -> TopBarWithBackButton(navController) + Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) + Route.Video.base -> StoriesTopBar(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) @Composable -fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return - val accountUserState by account.userProfile().live().metadata.observeAsState() - val accountUser = accountUserState?.user ?: return - val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState() val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState() val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - var wantsToEditRelays by remember { mutableStateOf(false) } @@ -115,48 +147,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - 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 - ) - } + content(account) } Column( @@ -186,23 +177,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) } }, navigationIcon = { - IconButton( - onClick = { - coroutineScope.launch { - 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) - ) + LoggedInUserPictureDrawer(account) { + coroutineScope.launch { + scaffoldState.drawerState.open() + } } }, 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, + explainers: List? = 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 fun TopBarWithBackButton(navController: NavHostController) { Column() { @@ -250,3 +329,51 @@ fun TopBarWithBackButton(navController: NavHostController) { 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 + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index af8adb2c3..5d7c6265c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,9 +51,11 @@ fun HomeScreen( ) { val coroutineScope = rememberCoroutineScope() val account = accountViewModel.accountLiveData.value?.account ?: return - var wantsToAddNip47 by remember { mutableStateOf(nip47) } + var wantsToAddNip47 by remember { mutableStateOf(nip47) } - LaunchedEffect(accountViewModel) { + val accountState = account.live.observeAsState() + + LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) { HomeNewThreadFeedFilter.account = account HomeConversationsFeedFilter.account = account NostrHomeDataSource.resetFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 6bed8d8b2..4ae9fef50 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -105,10 +105,14 @@ fun VideoScreen( val lifeCycleOwner = LocalLifecycleOwner.current val account = accountViewModel.accountLiveData.value?.account ?: return + val accountState = account.live.observeAsState() + + NostrVideoDataSource.account = account VideoFeedFilter.account = account - LaunchedEffect(accountViewModel) { + LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) { VideoFeedFilter.account = account + NostrVideoDataSource.account = account NostrVideoDataSource.resetFilters() videoFeedView.invalidateData() } @@ -118,6 +122,7 @@ fun VideoScreen( if (event == Lifecycle.Event.ON_RESUME) { println("Video Start") VideoFeedFilter.account = account + NostrVideoDataSource.account = account NostrVideoDataSource.start() videoFeedView.invalidateData() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a42bec5a5..143f5b6b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -348,4 +348,10 @@ Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support + + + Follow List + All Follows + Global +