Migrates contact list management to addressable notes
Creates new observable flows for LocalCache.
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
-80
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
-32
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-1
@@ -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()
|
||||||
|
|||||||
+25
-67
@@ -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() }
|
||||||
|
|||||||
+25
-31
@@ -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)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-10
@@ -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))
|
||||||
|
|||||||
+3
-11
@@ -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)) },
|
||||||
|
|||||||
+18
-6
@@ -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) {
|
||||||
|
|||||||
+45
-28
@@ -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 &&
|
||||||
|
|||||||
+2
-4
@@ -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)
|
||||||
|
|||||||
+24
-20
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-40
@@ -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
|
|
||||||
}
|
|
||||||
+46
-2
@@ -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,
|
||||||
|
|||||||
+24
-20
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-54
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+53
-2
@@ -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()
|
||||||
|
|||||||
-2
@@ -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)) {
|
||||||
|
|||||||
+14
-1
@@ -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(
|
||||||
|
|||||||
+6
-1
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user