Migrates contact list management to addressable notes

Creates new observable flows for LocalCache.
This commit is contained in:
Vitor Pamplona
2026-02-06 16:12:11 -05:00
parent 130a83f0b9
commit ec629ff081
25 changed files with 563 additions and 590 deletions
@@ -147,10 +147,8 @@ fun debugState(context: Context) {
)
Log.d(
STATE_DUMP_TAG,
"Observable Events: " +
LocalCache.observablesByKindAndETag.size +
" / " +
LocalCache.observablesByKindAndAuthor.size,
"Observables: " +
LocalCache.observables.size,
)
Log.d(
@@ -32,10 +32,11 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.model.observables.EventListMatchingFilter
import com.vitorpamplona.amethyst.model.observables.NoteListMatchingFilter
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
@@ -62,6 +63,9 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isRegular
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -72,13 +76,13 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
import com.vitorpamplona.quartz.nip01Core.tags.events.forEachTaggedEventId
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
@@ -197,16 +201,21 @@ import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
interface ILocalCache {
@@ -216,6 +225,15 @@ interface ILocalCache {
) {}
}
interface Observable {
fun new(
event: Event,
note: Note,
)
fun remove(note: Note)
}
object LocalCache : ILocalCache, ICacheProvider {
val antiSpam = AntiSpamFilter()
@@ -234,76 +252,103 @@ object LocalCache : ILocalCache, ICacheProvider {
val deletionIndex = DeletionIndex()
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
val observables = ConcurrentHashMap<Observable, Observable>(10)
fun <T : Event> observeETag(
kind: Int,
eventId: HexKey,
scope: CoroutineScope,
): LatestByKindWithETag<T> {
var eTagList = observablesByKindAndETag.get(kind)
if (eTagList == null) {
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
observablesByKindAndETag.put(kind, eTagList)
}
val value = eTagList.get(eventId)
return if (value != null) {
value
fun Filter.match(note: Note): Boolean {
val event = note.event
return if (event != null) {
match(event)
} else {
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event>
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
false
}
}
obj
} as LatestByKindWithETag<T>
fun filter(filter: Filter): SortedSet<Note> {
val byKinds = filter.kinds?.filter { it.isAddressable() || it.isReplaceable() }
val addressableMatches =
if (!byKinds.isNullOrEmpty()) {
val byAuthors = filter.authors
if (!byAuthors.isNullOrEmpty()) {
// optimized
byKinds.flatMap { kind ->
byAuthors.flatMap { pubkey ->
addressables.filter(kind, pubkey) { _, note ->
filter.match(note)
}
}
fun <T : Event> observeAuthor(
kind: Int,
pubkey: HexKey,
scope: CoroutineScope,
): LatestByKindAndAuthor<T> {
var authorObsList = observablesByKindAndAuthor.get(kind)
if (authorObsList == null) {
authorObsList = ConcurrentHashMap<HexKey, LatestByKindAndAuthor<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>
observablesByKindAndAuthor.put(kind, authorObsList)
}
val value = authorObsList.get(pubkey)
return if (value != null) {
value
} else {
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event>
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
// optimized
byKinds.flatMap { kind ->
addressables.filter(kind) { _, note ->
filter.match(note)
}
}
obj
} as LatestByKindAndAuthor<T>
}
private fun updateObservables(event: Event) {
observablesByKindAndETag[event.kind]?.let { observablesOfKind ->
event.forEachTaggedEventId {
observablesOfKind[it]?.updateIfMatches(event)
} else {
addressables.filter { _, note ->
filter.match(note)
}
}
observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event)
val noteMatches =
notes.filter { _, note ->
val event = note.event
if (event != null && event.kind.isRegular()) {
filter.match(event)
} else {
false
}
}
val limit = filter.limit
val limitedSet =
if (limit != null) {
(addressableMatches + noteMatches).take(limit)
} else {
(addressableMatches + noteMatches)
}
return limitedSet.toSortedSet(DefaultFeedOrder)
}
fun observeNotes(filter: Filter): Flow<List<Note>> =
callbackFlow {
val newFilter =
NoteListMatchingFilter(filter, this@LocalCache) {
trySend(it)
}
newFilter.init()
observables.put(newFilter, newFilter)
awaitClose {
observables.remove(newFilter)
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
fun observeEvents(filter: Filter): Flow<List<Event>> =
callbackFlow {
val cachedFilter =
EventListMatchingFilter(filter, this@LocalCache) {
trySend(it)
}
cachedFilter.init()
observables.put(cachedFilter, cachedFilter)
awaitClose {
observables.remove(cachedFilter)
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstNotNullOfOrNull { it as? T } }
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
@@ -334,6 +379,15 @@ object LocalCache : ILocalCache, ICacheProvider {
return count
}
fun countContactLists(predicate: (ContactListEvent) -> Boolean): Int {
var count = 0
addressables.filter(ContactListEvent.KIND).forEach { note ->
val event = note.event as? ContactListEvent
if (event != null && predicate(event)) count++
}
return count
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
@@ -522,25 +576,7 @@ object LocalCache : ILocalCache, ICacheProvider {
event: ContactListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val user = getOrCreateUser(event.pubKey)
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty() && (wasVerified || justVerify(event))) {
val needsToUpdateFollowers = user.updateContactList(event)
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
needsToUpdateFollowers.forEach {
getUserIfExists(it)?.flowSet?.followers?.invalidateData()
}
updateObservables(event)
return true
}
return false
}
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BookmarkListEvent,
@@ -2374,22 +2410,6 @@ object LocalCache : ILocalCache, ICacheProvider {
notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() }
users.forEach { _, it -> it.clearFlow() }
observablesByKindAndETag.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
observablesByKindAndAuthor.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
}
fun pruneHiddenMessagesChannel(
@@ -2670,24 +2690,6 @@ object LocalCache : ILocalCache, ICacheProvider {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun pruneContactLists(loggedIn: Set<HexKey>) {
checkNotInMainThread()
var removingContactList = 0
users.forEach { _, user ->
if (
user.pubkeyHex !in loggedIn &&
(user.flowSet == null || user.flowSet?.isInUse() == false) &&
user.latestContactList != null
) {
user.latestContactList = null
removingContactList++
}
}
println("PRUNE: $removingContactList contact lists")
}
override fun markAsSeen(
eventId: String,
relay: NormalizedRelayUrl,
@@ -2708,11 +2710,23 @@ object LocalCache : ILocalCache, ICacheProvider {
private fun refreshNewNoteObservers(newNote: Note) {
val event = newNote.event as Event
updateObservables(event)
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { t, u ->
u.new(event, newNote)
}
observables.forEach(observableBiConsumer)
live.newNote(newNote)
}
private fun refreshDeletedNoteObservers(newNote: Note) {
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { t, u ->
u.remove(newNote)
}
observables.forEach(observableBiConsumer)
live.removedNote(newNote)
}
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.nip02FollowLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -52,14 +52,19 @@ class Kind3FollowListState(
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val user = cache.getOrCreateUser(signer.pubKey)
fun getFollowListFlow(): StateFlow<UserState> = user.flow().follows.stateFlow
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val note = cache.getOrCreateAddressableNote(getFollowListAddress())
fun getFollowListEvent(): ContactListEvent? = user.latestContactList
fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey)
fun getFollowListFlow(): StateFlow<NoteState> = note.flow().metadata.stateFlow
fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent
@OptIn(ExperimentalCoroutinesApi::class)
private val innerFlow: Flow<Kind3Follows> =
getFollowListFlow().transformLatest {
emit(buildKind3Follows(it.user.latestContactList ?: settings.backupContactList))
emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList))
}
val flow =
@@ -149,7 +154,9 @@ class Kind3FollowListState(
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
getFollowListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
settings.updateContactListTo(it.user.latestContactList)
(it.note.event as? ContactListEvent)?.let {
settings.updateContactListTo(it)
}
}
}
}
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.Observable
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import java.util.SortedSet
/**
* Creates a list of events (regular and addressable)
* that is updated every time a new event that matches
* the filter is received, including addressables.
*/
class EventListMatchingFilter(
private val filter: Filter,
private val cache: LocalCache,
private val update: (List<Event>) -> Unit,
) : Observable {
// Keeping this here blocks it from being cleared from memory
var currentResults: SortedSet<Note> = sortedSetOf<Note>(DefaultFeedOrder)
override fun new(
event: Event,
note: Note,
) {
if (filter.match(event)) {
currentResults.add(note)
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
}
update(currentResults.mapNotNull { it.event })
}
}
override fun remove(note: Note) {
if (currentResults.remove(note)) {
update(currentResults.mapNotNull { it.event })
}
}
fun init() {
currentResults = cache.filter(filter)
update(currentResults.mapNotNull { it.event })
}
}
@@ -1,80 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindAndAuthor<T : Event>(
private val kind: Int,
private val pubkey: String,
) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
fun restart() {
val latestNote =
if ((kind in 10000..19999) || (kind in 30000..39999)) {
LocalCache.addressables
.maxOrNullOf(
filter = { address: Address, note: AddressableNote ->
address.kind == kind && address.pubKeyHex == pubkey
},
comparator = CreatedAtComparatorAddresses,
)?.event as? T
} else {
LocalCache.notes
.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind == kind && it.pubKey == pubkey
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
}
if (_latest.value != latestNote) {
_latest.tryEmit(latestNote)
}
}
suspend fun init() {
restart()
}
}
@@ -22,48 +22,49 @@ package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.Observable
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import java.util.SortedSet
class LatestByKindWithETag<T : Event>(
private val kind: Int,
private val eTag: String,
) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
/**
* Creates a list of notes (regular and addressable)
* that only gets updated when a new note appears.
*
* New versions of addressables do not update the list
*/
class NoteListMatchingFilter(
private val filter: Filter,
private val cache: LocalCache,
private val update: (List<Note>) -> Unit,
) : Observable {
var currentResults: SortedSet<Note> = sortedSetOf<Note>(DefaultFeedOrder)
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
override fun new(
event: Event,
note: Note,
) {
if (filter.match(event)) {
if (currentResults.add(note)) {
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
}
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
update(currentResults.toList())
}
}
}
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
fun restart() {
val latestNote =
LocalCache.notes
.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind == kind && it.isTaggedEvent(eTag)
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
if (_latest.value != latestNote) {
_latest.tryEmit(latestNote)
override fun remove(note: Note) {
if (currentResults.remove(note)) {
update(currentResults.toList())
}
}
suspend fun init() {
restart()
fun init() {
currentResults = cache.filter(filter)
update(currentResults.toList())
}
}
@@ -46,7 +46,6 @@ class MemoryTrimmingService(
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneContactLists(accounts)
cache.pruneRepliesAndReactions(accounts)
cache.prunePastVersionsOfReplaceables()
cache.pruneExpiredEvents()
@@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList
@@ -45,6 +45,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
@@ -178,21 +179,6 @@ fun observeUserPicture(
)
}
@Composable
fun observeUserFollows(
user: User,
accountViewModel: AccountViewModel,
): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.follows.stateFlow
.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserFollowCount(
@@ -202,17 +188,26 @@ fun observeUserFollowCount(
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
val note =
remember(user) {
accountViewModel.follows(user)
}
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
note
.flow()
.follows.stateFlow
.mapLatest { it.user.transientFollowCount() ?: 0 }
.metadata.stateFlow
.mapLatest { noteState ->
(noteState.note.event as? ContactListEvent)?.followCount() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(user.transientFollowCount() ?: 0)
return flow.collectAsStateWithLifecycle(
(note.event as? ContactListEvent)?.followCount() ?: 0,
)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -318,48 +313,6 @@ fun observeUserBookmarkCount(
return flow.collectAsStateWithLifecycle(0)
}
@Composable
fun observeUserFollowers(
user: User,
accountViewModel: AccountViewModel,
): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.followers.stateFlow
.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserFollowerCount(
user: User,
accountViewModel: AccountViewModel,
): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.followers.stateFlow
.sample(200)
.mapLatest { userState ->
LocalCache.countUsers { _, user ->
user.latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowing(
@@ -370,21 +323,26 @@ fun observeUserIsFollowing(
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user1, accountViewModel)
val note =
remember(user1) {
accountViewModel.follows(user1)
}
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user1) {
user1
note
.flow()
.follows.stateFlow
.sample(1000)
.metadata.stateFlow
.mapLatest { userState ->
userState.user.isFollowing(user2)
val event = userState.note.event as? ContactListEvent
event?.isFollowing(user2.pubkeyHex) ?: false
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(
user1.isFollowing(user2),
(note.event as? ContactListEvent)?.isFollowing(user2.pubkeyHex) ?: false,
)
}
@@ -22,9 +22,13 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
val DefaultFeedOrder: Comparator<Note> =
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
val DefaultFeedOrderEvent: Comparator<Event> =
compareByDescending<Event> { it.createdAt }.thenBy { it.id }
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
@@ -27,7 +27,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,8 +41,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarks
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -62,6 +59,9 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
@Composable
@@ -111,8 +111,7 @@ fun NoteDropDownMenu(
) {
var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember {
mutableStateOf(
val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
DropDownParams(
isFollowingAuthor = false,
isPrivateBookmarkNote = false,
@@ -122,7 +121,6 @@ fun NoteDropDownMenu(
showSensitiveContent = null,
),
)
}
val wantsToEditPost =
remember {
@@ -153,15 +151,8 @@ fun NoteDropDownMenu(
onDismissRequest = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) {
@@ -374,30 +365,33 @@ fun NoteDropDownMenu(
}
@Composable
fun WatchBookmarksFollowsAndAccount(
fun observeBookmarksFollowsAndAccount(
note: Note,
accountViewModel: AccountViewModel,
onNew: (DropDownParams) -> Unit,
) {
val followState by observeUserFollows(accountViewModel.userProfile(), accountViewModel)
val bookmarkState by observeUserBookmarks(accountViewModel.userProfile(), accountViewModel)
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
val newState =
) = remember(note) {
combine(
accountViewModel.account.kind3FollowList.flow,
accountViewModel.account.bookmarkState.bookmarks,
accountViewModel.showSensitiveContent(),
) { follows, bookmarks, showSensitiveContent ->
DropDownParams(
isFollowingAuthor = accountViewModel.isFollowing(note.author),
isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note),
isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note),
isFollowingAuthor = note.author?.pubkeyHex in follows.authors,
isPrivateBookmarkNote = note in bookmarks.private,
isPublicBookmarkNote = note in bookmarks.public,
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
showSensitiveContent = showSensitiveContent,
)
launch(Dispatchers.Main) {
onNew(
newState,
}.onStart {
emit(
DropDownParams(
isFollowingAuthor = note.author?.pubkeyHex?.let { accountViewModel.account.isFollowing(it) } ?: false,
isPrivateBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.private,
isPublicBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.public,
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
showSensitiveContent = accountViewModel.showSensitiveContent().value,
),
)
}
}
}.flowOn(Dispatchers.IO)
}
@@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
@@ -296,16 +297,6 @@ class AccountViewModel(
fun userProfile(): User = account.userProfile()
fun <T : Event> observeByETag(
kind: Int,
eTag: HexKey,
): StateFlow<T?> = LocalCache.observeETag<T>(kind = kind, eventId = eTag, viewModelScope).latest
fun <T : Event> observeByAuthor(
kind: Int,
pubkeyHex: HexKey,
): StateFlow<T?> = LocalCache.observeAuthor<T>(kind = kind, pubkey = pubkeyHex, viewModelScope).latest
fun reactToOrDelete(
note: Note,
reaction: String,
@@ -767,6 +758,8 @@ class AccountViewModel(
fun removeFromMediaGallery(note: Note) = launchSigner { account.removeFromGallery(note) }
fun follows(user: User): Note = LocalCache.getOrCreateAddressableNote(ContactListEvent.createAddress(user.pubkeyHex))
fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex))
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
@@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
@@ -45,7 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
import com.vitorpamplona.amethyst.ui.note.elements.WatchBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -106,8 +107,7 @@ fun BookmarkGroupItemOptionsMenu(
) {
var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember {
mutableStateOf(
val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
DropDownParams(
isFollowingAuthor = false,
isPrivateBookmarkNote = false,
@@ -117,7 +117,6 @@ fun BookmarkGroupItemOptionsMenu(
showSensitiveContent = null,
),
)
}
val wantsToEditPost =
remember {
@@ -148,15 +147,8 @@ fun BookmarkGroupItemOptionsMenu(
onDismissRequest = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
val scope = rememberCoroutineScope()
DropdownMenuItem(
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) },
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
@@ -177,13 +178,17 @@ fun ObserverContentDiscoveryResponse(
val resultFlow =
remember(dvmRequestId) {
accountViewModel.observeByETag<NIP90ContentDiscoveryResponseEvent>(
NIP90ContentDiscoveryResponseEvent.KIND,
dvmRequestId.idHex,
accountViewModel.account.cache
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
Filter(
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
tags = mapOf("e" to listOf(dvmRequestId.idHex)),
limit = 1,
),
)
}
val latestResponse by resultFlow.collectAsStateWithLifecycle()
val latestResponse by resultFlow.collectAsStateWithLifecycle(null)
val myResponse = latestResponse
if (myResponse != null) {
@@ -214,10 +219,17 @@ fun ObserverDvmStatusResponse(
) {
val statusFlow =
remember(dvmRequestId) {
accountViewModel.observeByETag<NIP90StatusEvent>(NIP90StatusEvent.KIND, dvmRequestId)
accountViewModel.account.cache
.observeLatestEvent<NIP90StatusEvent>(
Filter(
kinds = listOf(NIP90StatusEvent.KIND),
tags = mapOf("e" to listOf(dvmRequestId)),
limit = 1,
),
)
}
val latestStatus by statusFlow.collectAsStateWithLifecycle()
val latestStatus by statusFlow.collectAsStateWithLifecycle(null)
// TODO: Make a good splash screen with loading animation for this DVM.
if (latestStatus != null) {
@@ -31,43 +31,44 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
class NotificationFeedFilter(
val account: Account,
@@ -76,15 +77,42 @@ class NotificationFeedFilter(
val ADDRESSABLE_KINDS =
listOf(
AudioTrackEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarRSVPEvent.KIND,
ClassifiedsEvent.KIND,
LiveActivitiesEvent.KIND,
LongTextNoteEvent.KIND,
NipTextEvent.KIND,
VideoVerticalEvent.KIND,
VideoHorizontalEvent.KIND,
WikiNoteEvent.KIND,
)
val NOTIFICATION_KINDS =
setOf(
BadgeAwardEvent.KIND,
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
CommentEvent.KIND,
GenericRepostEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
LnZapEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,
PollNoteEvent.KIND,
PrivateDmEvent.KIND,
PublicMessageEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
VoiceEvent.KIND,
VoiceReplyEvent.KIND,
) + ADDRESSABLE_KINDS
}
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value
@@ -150,18 +178,7 @@ class NotificationFeedFilter(
}
}
return noteEvent !is ChannelCreateEvent &&
noteEvent !is ChannelMetadataEvent &&
noteEvent !is LnZapRequestEvent &&
noteEvent !is BadgeDefinitionEvent &&
noteEvent !is BadgeProfilesEvent &&
noteEvent !is NIP90ContentDiscoveryResponseEvent &&
noteEvent !is NIP90StatusEvent &&
noteEvent !is NIP90ContentDiscoveryRequestEvent &&
noteEvent !is GiftWrapEvent &&
noteEvent !is PrivateTagArrayEvent &&
noteEvent !is LnZapPaymentRequestEvent &&
noteEvent !is LnZapPaymentResponseEvent &&
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
@@ -263,8 +263,6 @@ fun ProfileScreen(
WatchLifecycleAndUpdateModel(threadsViewModel)
WatchLifecycleAndUpdateModel(repliesViewModel)
WatchLifecycleAndUpdateModel(mutualViewModel)
WatchLifecycleAndUpdateModel(followsFeedViewModel)
WatchLifecycleAndUpdateModel(followersFeedViewModel)
WatchLifecycleAndUpdateModel(appRecommendations)
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
WatchLifecycleAndUpdateModel(galleryFeedViewModel)
@@ -452,8 +450,8 @@ private fun CreateAndRenderPages(
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
2 -> TabMutualConversations(mutualViewModel, accountViewModel, nav)
3 -> TabGallery(galleryFeedViewModel, accountViewModel, nav)
4 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
5 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
4 -> TabFollows(followsFeedViewModel, accountViewModel, nav)
5 -> TabFollowers(followersFeedViewModel, accountViewModel, nav)
6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
8 -> TabFollowedTags(baseUser, accountViewModel, nav)
@@ -22,38 +22,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowers
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal.UserProfileFollowersUserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun TabFollowers(
baseUser: User,
feedViewModel: UserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchFollowerChanges(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
val items by followersFeedViewModel.followersFlow.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = rememberLazyListState(),
) {
itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@Composable
private fun WatchFollowerChanges(
baseUser: User,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
) {
val userState by observeUserFollowers(baseUser, accountViewModel)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -1,40 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
class UserProfileFollowersFeedFilter(
val user: User,
val account: Account,
) : FeedFilter<User>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex
override fun feed(): List<User> =
LocalCache.users.filter { _, it ->
it.isFollowing(user) && !account.isHidden(it)
}
override fun limit() = 400
}
@@ -23,15 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@Stable
class UserProfileFollowersUserFeedViewModel(
val user: User,
val account: Account,
) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) {
) : ViewModel() {
val followerFilter =
Filter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(user.pubkeyHex)),
)
val sortingModel: Comparator<User> =
compareBy(
{ !account.isFollowing(it) },
{ it.pubkeyHex },
)
fun List<Event>.toNonHiddenOwners(): List<User> =
mapNotNull { event ->
if (!account.isHidden(event.pubKey)) {
account.cache.getOrCreateUser(event.pubKey)
} else {
null
}
}
val followersFlow: StateFlow<List<User>> =
account.cache
.observeEvents(followerFilter)
.sample(500)
.map { followerContactLists ->
followerContactLists.toNonHiddenOwners().sortedWith(sortingModel)
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = emptyList(),
started = SharingStarted.Lazily,
)
class Factory(
val user: User,
val account: Account,
@@ -22,38 +22,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal.UserProfileFollowsUserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun TabFollows(
baseUser: User,
feedViewModel: UserFeedViewModel,
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchFollowChanges(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
val items by followsFeedViewModel.followsFlow.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = rememberLazyListState(),
) {
itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@Composable
private fun WatchFollowChanges(
baseUser: User,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
) {
val userState by observeUserFollows(baseUser, accountViewModel)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -1,54 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
class UserProfileFollowsFeedFilter(
val user: User,
val account: Account,
) : FeedFilter<User>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex
val cache: MutableMap<ContactListEvent, List<User>> = mutableMapOf()
override fun feed(): List<User> {
val contactList = user.latestContactList ?: return emptyList()
val previousList = cache[contactList]
if (previousList != null) return previousList
cache[contactList] =
user.latestContactList
?.unverifiedFollowKeySet()
?.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
?.toSet()
?.filter { !account.isHidden(it) }
?.reversed()
?: emptyList()
return cache[contactList] ?: emptyList()
}
}
@@ -23,15 +23,66 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@Stable
class UserProfileFollowsUserFeedViewModel(
val user: User,
val account: Account,
) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) {
) : ViewModel() {
val contactList = account.cache.getOrCreateAddressableNote(ContactListEvent.createAddress(user.pubkeyHex))
val sortingModel: Comparator<User> =
compareBy(
{ !account.isFollowing(it) },
{ it.pubkeyHex },
)
fun ContactListEvent.convertNonHiddenToUsers(): List<User> {
val nonHiddenFollows = verifiedFollowKeySet().filter { !account.isHidden(it) }
return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel)
}
val followsFlow: StateFlow<List<User>> =
contactList
.flow()
.metadata.stateFlow
.sample(200)
.map { noteState ->
(noteState.note.event as? ContactListEvent)?.convertNonHiddenToUsers() ?: emptyList()
}.onStart {
emit(
(contactList.event as? ContactListEvent)?.convertNonHiddenToUsers() ?: emptyList(),
)
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = emptyList(),
started = SharingStarted.Lazily,
)
val followCount =
followsFlow
.map { it.size }
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = 0,
started = SharingStarted.Lazily,
)
class Factory(
val user: User,
val account: Account,
@@ -28,13 +28,10 @@ import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -58,8 +55,6 @@ class User(
private var cards: UserCardsCache? = null
private var nip05: UserNip05Cache? = null
var latestContactList: ContactListEvent? = null
var zaps = mapOf<Note, Note?>()
private set
@@ -128,20 +123,6 @@ class User(
?.info
?.lnAddress()
fun updateContactList(event: ContactListEvent): Set<HexKey> {
if (event.id == latestContactList?.id) return emptySet()
val oldContactListEvent = latestContactList
latestContactList = event
// Update following of the current user
flowSet?.follows?.invalidateData()
val affectedUsers = event.verifiedFollowKeySet() + (oldContactListEvent?.verifiedFollowKeySet() ?: emptySet())
return affectedUsers
}
fun addZap(
zapRequest: Note,
zap: Note?,
@@ -214,10 +195,6 @@ class User(
nip05StateOrNull()?.newMetadata(newUserInfo.nip05, metaEvent.pubKey)
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
fun reportsOrNull(): UserReportCache? = reports
fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it }
@@ -322,15 +299,11 @@ class UserFlowSet(
u: User,
) {
// Observers line up here.
val follows = UserBundledRefresherFlow(u)
val followers = UserBundledRefresherFlow(u)
val usedRelays = UserBundledRefresherFlow(u)
val zaps = UserBundledRefresherFlow(u)
val statuses = UserBundledRefresherFlow(u)
fun isInUse(): Boolean =
follows.hasObservers() ||
followers.hasObservers() ||
usedRelays.hasObservers() ||
zaps.hasObservers() ||
statuses.hasObservers()
@@ -50,9 +50,7 @@ sealed interface Nip05State {
fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted)
suspend fun checkAndUpdate(nip05Client: Nip05Client) {
println("AABBCC checkAndUpdate $nip05")
if (verificationState.value.isExpired()) {
println("AABBCC Veryfing $nip05")
markAsVerifying()
try {
if (nip05Client.verify(nip05, hexKey)) {
@@ -21,13 +21,16 @@
package com.vitorpamplona.quartz.nip02FollowList
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -56,7 +59,11 @@ class ContactListEvent(
fun follows() = tags.mapNotNull(ContactTag::parseValid)
fun relays(): Map<NormalizedRelayUrl, ReadWrite>? {
fun isFollowing(pubKey: HexKey) = tags.any(ContactTag::isTagged, pubKey)
fun followCount() = tags.count(ContactTag::isTagged)
fun relays(): Map<NormalizedRelayUrl, ReadWrite> {
val regular = RelaySet.parse(content)
val normalized = mutableMapOf<NormalizedRelayUrl, ReadWrite>()
@@ -75,6 +82,12 @@ class ContactListEvent(
const val KIND = 3
const val ALT = "Follow List"
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey)
fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
suspend fun createFromScratch(
@@ -54,7 +54,12 @@ data class ContactTag(
companion object {
const val TAG_NAME = "p"
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun isTagged(
tag: Array<String>,
pubkey: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pubkey
fun parse(tag: Array<String>): ContactTag? {
ensure(tag.has(1)) { return null }