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( Log.d(
STATE_DUMP_TAG, STATE_DUMP_TAG,
"Observable Events: " + "Observables: " +
LocalCache.observablesByKindAndETag.size + LocalCache.observables.size,
" / " +
LocalCache.observablesByKindAndAuthor.size,
) )
Log.d( 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.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.EventListMatchingFilter
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag import com.vitorpamplona.amethyst.model.observables.NoteListMatchingFilter
import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent 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.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature
import com.vitorpamplona.quartz.nip01Core.crypto.verify 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.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd 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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag 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.isTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers 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.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow 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 kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
interface ILocalCache { interface ILocalCache {
@@ -216,6 +225,15 @@ interface ILocalCache {
) {} ) {}
} }
interface Observable {
fun new(
event: Event,
note: Note,
)
fun remove(note: Note)
}
object LocalCache : ILocalCache, ICacheProvider { object LocalCache : ILocalCache, ICacheProvider {
val antiSpam = AntiSpamFilter() val antiSpam = AntiSpamFilter()
@@ -234,76 +252,103 @@ object LocalCache : ILocalCache, ICacheProvider {
val deletionIndex = DeletionIndex() val deletionIndex = DeletionIndex()
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10) val observables = ConcurrentHashMap<Observable, Observable>(10)
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
fun <T : Event> observeETag( fun Filter.match(note: Note): Boolean {
kind: Int, val event = note.event
eventId: HexKey, return if (event != null) {
scope: CoroutineScope, match(event)
): 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
} else { } else {
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event> false
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
} }
} }
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 { } else {
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event> // optimized
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject byKinds.flatMap { kind ->
if (obj == newObject) { addressables.filter(kind) { _, note ->
// initialize filter.match(note)
scope.launch(Dispatchers.IO) {
obj.init()
} }
} }
obj
} as LatestByKindAndAuthor<T>
} }
} else {
private fun updateObservables(event: Event) { addressables.filter { _, note ->
observablesByKindAndETag[event.kind]?.let { observablesOfKind -> filter.match(note)
event.forEachTaggedEventId {
observablesOfKind[it]?.updateIfMatches(event)
} }
} }
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() fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
@@ -334,6 +379,15 @@ object LocalCache : ILocalCache, ICacheProvider {
return count 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(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
@@ -522,25 +576,7 @@ object LocalCache : ILocalCache, ICacheProvider {
event: ContactListEvent, event: ContactListEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ) = consumeBaseReplaceable(event, relay, wasVerified)
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
}
fun consume( fun consume(
event: BookmarkListEvent, event: BookmarkListEvent,
@@ -2374,22 +2410,6 @@ object LocalCache : ILocalCache, ICacheProvider {
notes.forEach { _, it -> it.clearFlow() } notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() } addressables.forEach { _, it -> it.clearFlow() }
users.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( fun pruneHiddenMessagesChannel(
@@ -2670,24 +2690,6 @@ object LocalCache : ILocalCache, ICacheProvider {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") 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( override fun markAsSeen(
eventId: String, eventId: String,
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
@@ -2708,11 +2710,23 @@ object LocalCache : ILocalCache, ICacheProvider {
private fun refreshNewNoteObservers(newNote: Note) { private fun refreshNewNoteObservers(newNote: Note) {
val event = newNote.event as Event 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) live.newNote(newNote)
} }
private fun refreshDeletedNoteObservers(newNote: Note) { private fun refreshDeletedNoteObservers(newNote: Note) {
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { t, u ->
u.remove(newNote)
}
observables.forEach(observableBiConsumer)
live.removedNote(newNote) live.removedNote(newNote)
} }
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.nip02FollowLists package com.vitorpamplona.amethyst.model.nip02FollowLists
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent 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 // 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) 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) @OptIn(ExperimentalCoroutinesApi::class)
private val innerFlow: Flow<Kind3Follows> = private val innerFlow: Flow<Kind3Follows> =
getFollowListFlow().transformLatest { getFollowListFlow().transformLatest {
emit(buildKind3Follows(it.user.latestContactList ?: settings.backupContactList)) emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList))
} }
val flow = val flow =
@@ -149,7 +154,9 @@ class Kind3FollowListState(
Log.d("AccountRegisterObservers", "Kind 3 Collector Start") Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
getFollowListFlow().collect { getFollowListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}") 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.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.flow.MutableStateFlow import java.util.SortedSet
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindWithETag<T : Event>( /**
private val kind: Int, * Creates a list of notes (regular and addressable)
private val eTag: String, * 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)
override fun new(
event: Event,
note: Note,
) { ) {
private val _latest = MutableStateFlow<T?>(null) if (filter.match(event)) {
val latest = _latest.asStateFlow() if (currentResults.add(note)) {
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
}
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag) update(currentResults.toList())
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
} }
} }
} }
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0 override fun remove(note: Note) {
if (currentResults.remove(note)) {
fun restart() { update(currentResults.toList())
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)
} }
} }
suspend fun init() { fun init() {
restart() currentResults = cache.filter(filter)
update(currentResults.toList())
} }
} }
@@ -46,7 +46,6 @@ class MemoryTrimmingService(
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages() cache.pruneOldMessages()
cache.pruneContactLists(accounts)
cache.pruneRepliesAndReactions(accounts) cache.pruneRepliesAndReactions(accounts)
cache.prunePastVersionsOfReplaceables() cache.prunePastVersionsOfReplaceables()
cache.pruneExpiredEvents() cache.pruneExpiredEvents()
@@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -45,6 +45,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest 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) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeUserFollowCount( fun observeUserFollowCount(
@@ -202,17 +188,26 @@ fun observeUserFollowCount(
// Subscribe in the relay for changes in the metadata of this user. // Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel) UserFinderFilterAssemblerSubscription(user, accountViewModel)
val note =
remember(user) {
accountViewModel.follows(user)
}
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
val flow = val flow =
remember(user) { remember(user) {
user note
.flow() .flow()
.follows.stateFlow .metadata.stateFlow
.mapLatest { it.user.transientFollowCount() ?: 0 } .mapLatest { noteState ->
(noteState.note.event as? ContactListEvent)?.followCount() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
} }
return flow.collectAsStateWithLifecycle(user.transientFollowCount() ?: 0) return flow.collectAsStateWithLifecycle(
(note.event as? ContactListEvent)?.followCount() ?: 0,
)
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -318,48 +313,6 @@ fun observeUserBookmarkCount(
return flow.collectAsStateWithLifecycle(0) 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) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeUserIsFollowing( fun observeUserIsFollowing(
@@ -370,21 +323,26 @@ fun observeUserIsFollowing(
// Subscribe in the relay for changes in the metadata of this user. // Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user1, accountViewModel) UserFinderFilterAssemblerSubscription(user1, accountViewModel)
val note =
remember(user1) {
accountViewModel.follows(user1)
}
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
val flow = val flow =
remember(user1) { remember(user1) {
user1 note
.flow() .flow()
.follows.stateFlow .metadata.stateFlow
.sample(1000)
.mapLatest { userState -> .mapLatest { userState ->
userState.user.isFollowing(user2) val event = userState.note.event as? ContactListEvent
event?.isFollowing(user2.pubkeyHex) ?: false
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
} }
return flow.collectAsStateWithLifecycle( 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.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
val DefaultFeedOrder: Comparator<Note> = val DefaultFeedOrder: Comparator<Note> =
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex } compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
val DefaultFeedOrderEvent: Comparator<Event> =
compareByDescending<Event> { it.createdAt }.thenBy { it.id }
val DefaultFeedOrderCard: Comparator<Card> = val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() } compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
@@ -27,7 +27,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -42,8 +41,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note 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.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable 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.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -111,8 +111,7 @@ fun NoteDropDownMenu(
) { ) {
var reportDialogShowing by remember { mutableStateOf(false) } var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember { val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
mutableStateOf(
DropDownParams( DropDownParams(
isFollowingAuthor = false, isFollowingAuthor = false,
isPrivateBookmarkNote = false, isPrivateBookmarkNote = false,
@@ -122,7 +121,6 @@ fun NoteDropDownMenu(
showSensitiveContent = null, showSensitiveContent = null,
), ),
) )
}
val wantsToEditPost = val wantsToEditPost =
remember { remember {
@@ -153,15 +151,8 @@ fun NoteDropDownMenu(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current val actContext = LocalContext.current
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) { if (!state.isFollowingAuthor) {
@@ -374,30 +365,33 @@ fun NoteDropDownMenu(
} }
@Composable @Composable
fun WatchBookmarksFollowsAndAccount( fun observeBookmarksFollowsAndAccount(
note: Note, note: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onNew: (DropDownParams) -> Unit, ) = remember(note) {
) { combine(
val followState by observeUserFollows(accountViewModel.userProfile(), accountViewModel) accountViewModel.account.kind3FollowList.flow,
val bookmarkState by observeUserBookmarks(accountViewModel.userProfile(), accountViewModel) accountViewModel.account.bookmarkState.bookmarks,
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() accountViewModel.showSensitiveContent(),
) { follows, bookmarks, showSensitiveContent ->
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
val newState =
DropDownParams( DropDownParams(
isFollowingAuthor = accountViewModel.isFollowing(note.author), isFollowingAuthor = note.author?.pubkeyHex in follows.authors,
isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note), isPrivateBookmarkNote = note in bookmarks.private,
isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note), isPublicBookmarkNote = note in bookmarks.public,
isLoggedUser = accountViewModel.isLoggedUser(note.author), isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitiveOrNSFW() ?: false, isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
showSensitiveContent = showSensitiveContent, showSensitiveContent = showSensitiveContent,
) )
}.onStart {
launch(Dispatchers.Main) { emit(
onNew( DropDownParams(
newState, 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.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
@@ -296,16 +297,6 @@ class AccountViewModel(
fun userProfile(): User = account.userProfile() 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( fun reactToOrDelete(
note: Note, note: Note,
reaction: String, reaction: String,
@@ -767,6 +758,8 @@ class AccountViewModel(
fun removeFromMediaGallery(note: Note) = launchSigner { account.removeFromGallery(note) } 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 hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex))
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(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.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView 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.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams 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.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -106,8 +107,7 @@ fun BookmarkGroupItemOptionsMenu(
) { ) {
var reportDialogShowing by remember { mutableStateOf(false) } var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember { val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
mutableStateOf(
DropDownParams( DropDownParams(
isFollowingAuthor = false, isFollowingAuthor = false,
isPrivateBookmarkNote = false, isPrivateBookmarkNote = false,
@@ -117,7 +117,6 @@ fun BookmarkGroupItemOptionsMenu(
showSensitiveContent = null, showSensitiveContent = null,
), ),
) )
}
val wantsToEditPost = val wantsToEditPost =
remember { remember {
@@ -148,15 +147,8 @@ fun BookmarkGroupItemOptionsMenu(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current val actContext = LocalContext.current
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) }, 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.SimpleImage75Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
@@ -177,13 +178,17 @@ fun ObserverContentDiscoveryResponse(
val resultFlow = val resultFlow =
remember(dvmRequestId) { remember(dvmRequestId) {
accountViewModel.observeByETag<NIP90ContentDiscoveryResponseEvent>( accountViewModel.account.cache
NIP90ContentDiscoveryResponseEvent.KIND, .observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
dvmRequestId.idHex, 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 val myResponse = latestResponse
if (myResponse != null) { if (myResponse != null) {
@@ -214,10 +219,17 @@ fun ObserverDvmStatusResponse(
) { ) {
val statusFlow = val statusFlow =
remember(dvmRequestId) { 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. // TODO: Make a good splash screen with loading animation for this DVM.
if (latestStatus != null) { 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.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent 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.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent 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.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent 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.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent 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.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent 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.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent 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.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
class NotificationFeedFilter( class NotificationFeedFilter(
val account: Account, val account: Account,
@@ -76,15 +77,42 @@ class NotificationFeedFilter(
val ADDRESSABLE_KINDS = val ADDRESSABLE_KINDS =
listOf( listOf(
AudioTrackEvent.KIND, AudioTrackEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
CalendarTimeSlotEvent.KIND, CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND, CalendarDateSlotEvent.KIND,
CalendarRSVPEvent.KIND, CalendarRSVPEvent.KIND,
ClassifiedsEvent.KIND,
LiveActivitiesEvent.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 override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value
@@ -150,18 +178,7 @@ class NotificationFeedFilter(
} }
} }
return noteEvent !is ChannelCreateEvent && return noteEvent?.kind in NOTIFICATION_KINDS &&
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 &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && (filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
noteEvent?.isTaggedUser(loggedInUserHex) ?: false && noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
@@ -263,8 +263,6 @@ fun ProfileScreen(
WatchLifecycleAndUpdateModel(threadsViewModel) WatchLifecycleAndUpdateModel(threadsViewModel)
WatchLifecycleAndUpdateModel(repliesViewModel) WatchLifecycleAndUpdateModel(repliesViewModel)
WatchLifecycleAndUpdateModel(mutualViewModel) WatchLifecycleAndUpdateModel(mutualViewModel)
WatchLifecycleAndUpdateModel(followsFeedViewModel)
WatchLifecycleAndUpdateModel(followersFeedViewModel)
WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(appRecommendations)
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
WatchLifecycleAndUpdateModel(galleryFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel)
@@ -452,8 +450,8 @@ private fun CreateAndRenderPages(
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
2 -> TabMutualConversations(mutualViewModel, accountViewModel, nav) 2 -> TabMutualConversations(mutualViewModel, accountViewModel, nav)
3 -> TabGallery(galleryFeedViewModel, accountViewModel, nav) 3 -> TabGallery(galleryFeedViewModel, accountViewModel, nav)
4 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) 4 -> TabFollows(followsFeedViewModel, accountViewModel, nav)
5 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) 5 -> TabFollowers(followersFeedViewModel, accountViewModel, nav)
6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) 6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) 7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
8 -> TabFollowedTags(baseUser, 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.Column
import androidx.compose.foundation.layout.fillMaxHeight 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.User import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowers
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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 @Composable
fun TabFollowers( fun TabFollowers(
baseUser: User, followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
WatchFollowerChanges(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) val items by followersFeedViewModel.followersFlow.collectAsStateWithLifecycle()
}
}
@Composable LazyColumn(
private fun WatchFollowerChanges( modifier = Modifier.fillMaxSize(),
baseUser: User, contentPadding = FeedPadding,
feedViewModel: UserFeedViewModel, state = rememberLazyListState(),
accountViewModel: AccountViewModel,
) { ) {
val userState by observeUserFollowers(baseUser, accountViewModel) itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
LaunchedEffect(userState) { feedViewModel.invalidateData() } HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
} }
@@ -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.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User 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 @Stable
class UserProfileFollowersUserFeedViewModel( class UserProfileFollowersUserFeedViewModel(
val user: User, val user: User,
val account: Account, 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( class Factory(
val user: User, val user: User,
val account: Account, 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.Column
import androidx.compose.foundation.layout.fillMaxHeight 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.User import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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 @Composable
fun TabFollows( fun TabFollows(
baseUser: User, followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
WatchFollowChanges(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) val items by followsFeedViewModel.followsFlow.collectAsStateWithLifecycle()
}
}
@Composable LazyColumn(
private fun WatchFollowChanges( modifier = Modifier.fillMaxSize(),
baseUser: User, contentPadding = FeedPadding,
feedViewModel: UserFeedViewModel, state = rememberLazyListState(),
accountViewModel: AccountViewModel,
) { ) {
val userState by observeUserFollows(baseUser, accountViewModel) itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
LaunchedEffect(userState) { feedViewModel.invalidateData() } HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
} }
@@ -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.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User 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 @Stable
class UserProfileFollowsUserFeedViewModel( class UserProfileFollowsUserFeedViewModel(
val user: User, val user: User,
val account: Account, 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( class Factory(
val user: User, val user: User,
val account: Account, 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.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06 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.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag 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.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -58,8 +55,6 @@ class User(
private var cards: UserCardsCache? = null private var cards: UserCardsCache? = null
private var nip05: UserNip05Cache? = null private var nip05: UserNip05Cache? = null
var latestContactList: ContactListEvent? = null
var zaps = mapOf<Note, Note?>() var zaps = mapOf<Note, Note?>()
private set private set
@@ -128,20 +123,6 @@ class User(
?.info ?.info
?.lnAddress() ?.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( fun addZap(
zapRequest: Note, zapRequest: Note,
zap: Note?, zap: Note?,
@@ -214,10 +195,6 @@ class User(
nip05StateOrNull()?.newMetadata(newUserInfo.nip05, metaEvent.pubKey) 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 reportsOrNull(): UserReportCache? = reports
fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it } fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it }
@@ -322,15 +299,11 @@ class UserFlowSet(
u: User, u: User,
) { ) {
// Observers line up here. // Observers line up here.
val follows = UserBundledRefresherFlow(u)
val followers = UserBundledRefresherFlow(u)
val usedRelays = UserBundledRefresherFlow(u) val usedRelays = UserBundledRefresherFlow(u)
val zaps = UserBundledRefresherFlow(u) val zaps = UserBundledRefresherFlow(u)
val statuses = UserBundledRefresherFlow(u) val statuses = UserBundledRefresherFlow(u)
fun isInUse(): Boolean = fun isInUse(): Boolean =
follows.hasObservers() ||
followers.hasObservers() ||
usedRelays.hasObservers() || usedRelays.hasObservers() ||
zaps.hasObservers() || zaps.hasObservers() ||
statuses.hasObservers() statuses.hasObservers()
@@ -50,9 +50,7 @@ sealed interface Nip05State {
fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted) fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted)
suspend fun checkAndUpdate(nip05Client: Nip05Client) { suspend fun checkAndUpdate(nip05Client: Nip05Client) {
println("AABBCC checkAndUpdate $nip05")
if (verificationState.value.isExpired()) { if (verificationState.value.isExpired()) {
println("AABBCC Veryfing $nip05")
markAsVerifying() markAsVerifying()
try { try {
if (nip05Client.verify(nip05, hexKey)) { if (nip05Client.verify(nip05, hexKey)) {
@@ -21,13 +21,16 @@
package com.vitorpamplona.quartz.nip02FollowList package com.vitorpamplona.quartz.nip02FollowList
import androidx.compose.runtime.Stable 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.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync 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.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -56,7 +59,11 @@ class ContactListEvent(
fun follows() = tags.mapNotNull(ContactTag::parseValid) 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 regular = RelaySet.parse(content)
val normalized = mutableMapOf<NormalizedRelayUrl, ReadWrite>() val normalized = mutableMapOf<NormalizedRelayUrl, ReadWrite>()
@@ -75,6 +82,12 @@ class ContactListEvent(
const val KIND = 3 const val KIND = 3
const val ALT = "Follow List" 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:" fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
suspend fun createFromScratch( suspend fun createFromScratch(
@@ -54,7 +54,12 @@ data class ContactTag(
companion object { companion object {
const val TAG_NAME = "p" 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? { fun parse(tag: Array<String>): ContactTag? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }