- Restructures the old static datasource model into dynamic filter assemblers.

- Moves filter assemblers, viewModels and DAL classes to their own packages.
- Creates Composable observers for Users and Notes
- Deletes most of the secondary LiveData objects in the move to Flow
- Manipulates nostr filters depending on the account of the current screen, not a global account.
- Unifies all FilterAssembly lifecycle watchers to a few classes.
- Prepares to separate The Nostr Client as an Engine of Amethyst.
- Moves the pre-caching processor of new events from the datasource to the accountViewModel
- Reorganizes search to be per-screen basis
- Moves authentication to a fixed Coordinator class for all accounts in all relays.
- Moves NOTIFY command to its own coordinator class for all accounts
- Moves the connection between filters and cache to its own class.
- Significantly reduces the dependency on a single ServiceManager class.
This commit is contained in:
Vitor Pamplona
2025-04-23 16:24:41 -04:00
parent faf0f6dc1a
commit a84371a0b3
272 changed files with 7055 additions and 5068 deletions
@@ -25,7 +25,7 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
@@ -26,7 +26,9 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -38,6 +40,10 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.ammolite.relays.NostrClient
@@ -53,6 +59,7 @@ import java.io.File
class Amethyst : Application() {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
@@ -60,7 +67,7 @@ class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Key cache to download and decrypt encrypted files before caching them.
// Key cache service to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// App services that should be run as soon as there are subscribers to their flows
@@ -68,9 +75,10 @@ class Amethyst : Application() {
val torManager = TorManager(this, applicationIOScope)
val connManager = ConnectivityManager(this, applicationIOScope)
// Service that will run at all times.
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// creates okHttpClients based on the conditions of the connection and tor status
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
@@ -80,21 +88,52 @@ class Amethyst : Application() {
scope = applicationIOScope,
)
// Connects the NostrClient class with okHttp
val factory =
OkHttpWebSocket.BuilderFactory { _, useProxy ->
okHttpClients.getHttpClient(useProxy)
}
// Provides a relay pool
val client: NostrClient = NostrClient(factory)
// Caches all events in Memory
val cache: LocalCache = LocalCache
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client)
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
// Trash.
val serviceManager = ServiceManager(client, applicationIOScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) }
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) }
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) }
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache() }
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
override fun onCreate() {
@@ -103,7 +142,7 @@ class Amethyst : Application() {
instance = this
if (isDebug()) {
if (isDebug) {
Logging.setup()
}
@@ -124,10 +163,8 @@ class Amethyst : Application() {
fun contentResolverFn(): ContentResolver = contentResolver
fun isDebug() = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
fun setImageLoader(shouldUseTor: Boolean?) =
ImageLoaderSetup.setup(this, diskCache, memoryCache, isDebug()) {
ImageLoaderSetup.setup(this, diskCache, memoryCache) {
shouldUseTor?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
}
@@ -142,7 +179,7 @@ class Amethyst : Application() {
super.onTrimMemory(level)
Log.d("AmethystApp", "onTrimMemory $level")
applicationIOScope.launch(Dispatchers.Default) {
serviceManager.trimMemory()
trimmingService.run(null, LocalPreferences.allSavedAccounts())
}
}
@@ -27,45 +27,15 @@ import android.os.Debug
import android.util.Log
import androidx.core.content.getSystemService
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import kotlin.time.measureTimedValue
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
fun debugState(context: Context) {
Amethyst.instance.client
.allSubscriptions()
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrCommunityDataSource.printCounter()
NostrDiscoveryDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrGeohashDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrVideoDataSource.printCounter()
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
@@ -174,3 +144,27 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
}
}
inline fun <T> logTime(
debugMessage: String,
block: () -> T,
): T =
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
Log.d("DEBUG-TIME", "$elapsed: $debugMessage")
result
} else {
block()
}
inline fun <T> logTime(
debugMessage: (T) -> Unit,
block: () -> T,
): T =
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
Log.d("DEBUG-TIME", "$elapsed: ${debugMessage(result)}")
result
} else {
block()
}
@@ -25,35 +25,12 @@ import androidx.compose.runtime.Stable
import coil3.annotation.DelicateCoilApi
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@Stable
class ServiceManager(
@@ -67,8 +44,6 @@ class ServiceManager(
private var collectorJob: Job? = null
private val trimmingService = MemoryTrimmingService()
private fun start(account: Account) {
this.account = account
start()
@@ -104,39 +79,6 @@ class ServiceManager(
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
runBlocking {
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
GlobalScope.launch(Dispatchers.IO) {
delay(3000)
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
NostrVideoDataSource.start()
}
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
isStarted = true
}
}
@@ -147,24 +89,6 @@ class ServiceManager(
collectorJob?.cancel()
collectorJob = null
NostrAccountDataSource.stopSync()
NostrHomeDataSource.stopSync()
NostrChannelDataSource.stopSync()
NostrChatroomDataSource.stopSync()
NostrChatroomListDataSource.stopSync()
NostrDiscoveryDataSource.stopSync()
NostrCommunityDataSource.stopSync()
NostrHashtagDataSource.stopSync()
NostrGeohashDataSource.stopSync()
NostrSearchEventOrUserDataSource.stopSync()
NostrSingleChannelDataSource.stopSync()
NostrSingleEventDataSource.stopSync()
NostrSingleUserDataSource.stopSync()
NostrThreadDataSource.stopSync()
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
client.reconnect(null)
isStarted = false
}
@@ -173,10 +97,6 @@ class ServiceManager(
LocalCache.cleanObservers()
}
suspend fun trimMemory() {
trimmingService.run(account)
}
// This method keeps the pause/start in a Syncronized block to
// avoid concurrent pauses and starts.
@Synchronized
@@ -33,10 +33,10 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.ots.OtsResolverBuilder
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
@@ -220,14 +220,6 @@ class Account(
var transientHiddenUsers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf())
data class PaymentRequest(
val relayUrl: String,
val description: String,
)
var transientPaymentRequestDismissals: Set<PaymentRequest> = emptySet()
val transientPaymentRequests: MutableStateFlow<Set<PaymentRequest>> = MutableStateFlow(emptySet())
@Immutable
class LiveFollowList(
val authors: Set<String> = emptySet(),
@@ -1101,7 +1093,7 @@ class Account(
val liveHiddenUsers = flowHiddenUsers.asLiveData()
val decryptBookmarks: LiveData<BookmarkListEvent?> by lazy {
userProfile().live().innerBookmarks.switchMap { userState ->
userProfile().live().bookmarks.switchMap { userState ->
liveData(Dispatchers.IO) {
if (userState.user.latestBookmarkList == null) {
emit(null)
@@ -1190,22 +1182,6 @@ class Account(
)
}
fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) {
if (
!this.transientPaymentRequests.value.contains(paymentRequest) &&
!this.transientPaymentRequestDismissals.contains(paymentRequest)
) {
this.transientPaymentRequests.value += paymentRequest
}
}
fun dismissPaymentRequest(request: PaymentRequest) {
if (this.transientPaymentRequests.value.contains(request)) {
this.transientPaymentRequests.value -= request
this.transientPaymentRequestDismissals += request
}
}
private var userProfileCache: User? = null
fun userProfile(): User = userProfileCache ?: LocalCache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
@@ -1564,14 +1540,15 @@ class Account(
nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer
LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event ->
val wcListener =
NostrLnZapPaymentResponseDataSource(
val filter =
NWCPaymentQueryState(
fromServiceHex = nip47.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
authSigner = signer,
)
wcListener.startSync()
Amethyst.instance.sources.nwc
.subscribe(filter)
LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } }
@@ -1583,9 +1560,12 @@ class Account(
forceProxy = shouldUseTorForTrustedRelays(), // this is trusted.
read = true,
write = true,
feedTypes = wcListener.feedTypes,
feedTypes = setOf(FeedType.WALLET_CONNECT),
),
onDone = { wcListener.destroy() },
onDone = {
Amethyst.instance.sources.nwc
.unsubscribe(filter)
},
)
onSent()
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -230,12 +229,11 @@ abstract class Channel(
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
fun pruneOldMessages(): Set<Note> {
val important =
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}.sortedWith(DefaultFeedOrder)
.values()
.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
@@ -245,6 +243,18 @@ abstract class Channel(
return toBeRemoved.toSet()
}
fun pruneHiddenMessages(account: Account): Set<Note> {
val hidden =
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == true
}.toSet()
hidden.forEach { notes.remove(it.idHex) }
return hidden.toSet()
}
}
class ChannelLiveData(
@@ -263,16 +273,6 @@ class ChannelLiveData(
}
}
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel)
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel)
}
}
class ChannelState(
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.data.DeletionIndex
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.ammolite.relays.Relay
@@ -171,7 +170,26 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
interface ILocalCache {
fun verifyAndConsume(
event: Event,
relay: Relay?,
)
fun justVerify(event: Event): Boolean
fun consume(
event: DraftEvent,
relay: Relay?,
)
fun markAsSeen(
string: String,
relay: Relay,
) {}
}
object LocalCache : ILocalCache {
val antiSpam = AntiSpamFilter()
val users = LargeCache<HexKey, User>()
@@ -185,6 +203,8 @@ object LocalCache {
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
val onNewEvents = mutableListOf<(Note) -> Unit>()
fun <T : Event> observeETag(
kind: Int,
eventId: HexKey,
@@ -452,6 +472,7 @@ object LocalCache {
}
}
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}")
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()}
@@ -917,7 +938,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
author.liveSet?.innerStatuses?.invalidateData()
author.liveSet?.statuses?.invalidateData()
refreshObservers(note)
}
@@ -942,7 +963,7 @@ object LocalCache {
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.liveSet?.innerOts?.invalidateData()
version.liveSet?.ots?.invalidateData()
}
refreshObservers(version)
@@ -1358,7 +1379,7 @@ object LocalCache {
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.innerReports?.invalidateData()
it.liveSet?.reports?.invalidateData()
}
}
@@ -1604,7 +1625,7 @@ object LocalCache {
checkGetOrCreateNote(it.eventId)?.let { editedNote ->
modificationCache.remove(editedNote.idHex)
// must update list of Notes to quickly update the user.
editedNote.liveSet?.innerModifications?.invalidateData()
editedNote.liveSet?.edits?.invalidateData()
}
}
@@ -1832,6 +1853,8 @@ object LocalCache {
username: String,
forAccount: Account?,
): List<User> {
if (username.isBlank()) return emptyList()
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
@@ -1884,6 +1907,8 @@ object LocalCache {
): List<Note> {
checkNotInMainThread()
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
@@ -1948,6 +1973,8 @@ object LocalCache {
fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread()
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null && getChannelIfExists(key) != null) {
return listOfNotNull(getChannelIfExists(key))
@@ -2054,11 +2081,9 @@ object LocalCache {
}
}
fun pruneOldAndHiddenMessages(account: Account) {
checkNotInMainThread()
fun pruneHiddenMessages(account: Account) {
channels.forEach { _, channel ->
val toBeRemoved = channel.pruneOldAndHiddenMessages(account)
val toBeRemoved = channel.pruneHiddenMessages(account)
val childrenToBeRemoved = mutableListOf<Note>()
@@ -2072,7 +2097,31 @@ object LocalCache {
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
"PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
}
fun pruneOldMessages() {
checkNotInMainThread()
channels.forEach { _, channel ->
val toBeRemoved = channel.pruneOldMessages()
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeFromCache(childrenToBeRemoved)
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
@@ -2258,7 +2307,7 @@ object LocalCache {
}
}
fun pruneHiddenMessages(account: Account) {
fun pruneHiddenEvents(account: Account) {
checkNotInMainThread()
val childrenToBeRemoved = mutableListOf<Note>()
@@ -2299,15 +2348,32 @@ object LocalCache {
println("PRUNE: $removingContactList contact lists")
}
override fun markAsSeen(
eventId: String,
relay: Relay,
) {
val note = getNoteIfExists(eventId)
note?.event?.let { noteEvent ->
if (noteEvent is AddressableEvent) {
getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay)
}
}
note?.addRelay(relay)
}
// Observers line up here.
val live: LocalCacheFlow = LocalCacheFlow()
private fun refreshObservers(newNote: Note) {
updateObservables(newNote.event as Event)
val event = newNote.event as Event
updateObservables(event)
onNewEvents.forEach { it(newNote) }
live.invalidateData(newNote)
}
fun verifyAndConsume(
override fun verifyAndConsume(
event: Event,
relay: Relay?,
) {
@@ -2316,7 +2382,7 @@ object LocalCache {
}
}
fun justVerify(event: Event): Boolean {
override fun justVerify(event: Event): Boolean {
checkNotInMainThread()
return if (!event.verify()) {
@@ -2332,7 +2398,7 @@ object LocalCache {
}
}
fun consume(
override fun consume(
event: DraftEvent,
relay: Relay?,
) {
@@ -2639,6 +2705,16 @@ object LocalCache {
val note = notes.get(notificationEvent.id)
note?.event != null
}
fun copyRelaysFromTo(
from: Note,
to: Event,
) {
val toNote = getOrCreateNote(to)
from.relays.forEach {
toNote.addRelayBrief(it)
}
}
}
@Stable
@@ -23,15 +23,11 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
@@ -232,7 +228,7 @@ open class Note(
this.author = author
this.replyTo = replyTo
liveSet?.innerMetadata?.invalidateData()
liveSet?.metadata?.invalidateData()
flowSet?.metadata?.invalidateData()
}
}
@@ -240,21 +236,21 @@ open class Note(
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.innerReplies?.invalidateData()
liveSet?.replies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.innerReplies?.invalidateData()
liveSet?.replies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.innerBoosts?.invalidateData()
liveSet?.boosts?.invalidateData()
}
}
@@ -285,13 +281,13 @@ open class Note(
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
if (repliesChanged) liveSet?.replies?.invalidateData()
if (reactionsChanged) liveSet?.reactions?.invalidateData()
if (boostsChanged) liveSet?.boosts?.invalidateData()
if (reportsChanged) {
flowSet?.reports?.invalidateData()
}
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
if (zapsChanged) liveSet?.zaps?.invalidateData()
return toBeRemoved
}
@@ -310,7 +306,7 @@ open class Note(
reactions = reactions + Pair(reaction, newList)
}
liveSet?.innerReactions?.invalidateData()
liveSet?.reactions?.invalidateData()
}
}
}
@@ -331,28 +327,28 @@ open class Note(
if (zaps[note] != null) {
zaps = zaps.minus(note)
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
} else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note }
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments.containsKey(note)) {
zapPayments = zapPayments.minus(note)
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
zapPayments = zapPayments.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.innerBoosts?.invalidateData()
liveSet?.boosts?.invalidateData()
}
}
@@ -379,7 +375,7 @@ open class Note(
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
}
@@ -405,7 +401,7 @@ open class Note(
if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
}
@@ -417,10 +413,10 @@ open class Note(
val listOfAuthors = reactions[reaction]
if (listOfAuthors == null) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.innerReactions?.invalidateData()
liveSet?.reactions?.invalidateData()
} else if (!listOfAuthors.contains(note)) {
reactions = reactions + Pair(reaction, listOfAuthors + note)
liveSet?.innerReactions?.invalidateData()
liveSet?.reactions?.invalidateData()
}
}
@@ -1001,44 +997,13 @@ class NoteLiveSet(
u: Note,
) {
// Observers line up here.
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val innerModifications = NoteBundledRefresherLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val zaps = innerZaps.map { it }
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
val hasReactions =
innerZaps
.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty()
?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged()
val reactionCount =
innerReactions
.map {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}.distinctUntilChanged()
val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged()
val content = innerMetadata.map { it.note.event?.content ?: "" }
val metadata = NoteBundledRefresherLiveData(u)
val reactions = NoteBundledRefresherLiveData(u)
val boosts = NoteBundledRefresherLiveData(u)
val replies = NoteBundledRefresherLiveData(u)
val zaps = NoteBundledRefresherLiveData(u)
val ots = NoteBundledRefresherLiveData(u)
val edits = NoteBundledRefresherLiveData(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
@@ -1046,22 +1011,17 @@ class NoteLiveSet(
boosts.hasObservers() ||
replies.hasObservers() ||
zaps.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
innerOts.hasObservers() ||
innerModifications.hasObservers()
ots.hasObservers() ||
edits.hasObservers()
fun destroy() {
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerZaps.destroy()
innerOts.destroy()
innerModifications.destroy()
metadata.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
zaps.destroy()
ots.destroy()
edits.destroy()
}
}
@@ -1109,37 +1069,6 @@ class NoteBundledRefresherLiveData(
postValue(NoteState(note))
}
}
fun <Y> map(transform: (NoteState) -> Y): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
@Stable
class NoteLoadingLiveData<Y>(
val note: Note,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
NostrSingleEventDataSource.addAddress(note)
} else {
NostrSingleEventDataSource.add(note)
}
}
override fun onInactive() {
super.onInactive()
if (note is AddressableNote) {
NostrSingleEventDataSource.removeAddress(note)
} else {
NostrSingleEventDataSource.remove(note)
}
}
}
@Immutable class NoteState(
@@ -23,9 +23,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
@@ -128,7 +125,7 @@ class User(
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.innerBookmarks?.invalidateData()
liveSet?.bookmarks?.invalidateData()
}
fun clearEOSE() {
@@ -142,7 +139,7 @@ class User(
latestContactList = event
// Update following of the current user
liveSet?.innerFollows?.invalidateData()
liveSet?.follows?.invalidateData()
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
@@ -151,18 +148,18 @@ class User(
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.followers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.followers
?.invalidateData()
}
liveSet?.innerRelays?.invalidateData()
liveSet?.relays?.invalidateData()
flowSet?.relays?.invalidateData()
}
@@ -172,10 +169,10 @@ class User(
val reportsBy = reports[author]
if (reportsBy == null) {
reports = reports + Pair(author, setOf(note))
liveSet?.innerReports?.invalidateData()
liveSet?.reports?.invalidateData()
} else if (!reportsBy.contains(note)) {
reports = reports + Pair(author, reportsBy + note)
liveSet?.innerReports?.invalidateData()
liveSet?.reports?.invalidateData()
}
}
@@ -185,7 +182,7 @@ class User(
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
liveSet?.reports?.invalidateData()
}
}
}
@@ -196,17 +193,17 @@ class User(
) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.innerZaps?.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
@@ -259,7 +256,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
liveSet?.messages?.invalidateData()
}
}
@@ -270,7 +267,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
liveSet?.messages?.invalidateData()
}
}
@@ -287,7 +284,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
liveSet?.messages?.invalidateData()
}
}
@@ -299,7 +296,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
liveSet?.messages?.invalidateData()
}
}
@@ -317,7 +314,7 @@ class User(
here.counter++
}
liveSet?.innerRelayInfo?.invalidateData()
liveSet?.relayInfo?.invalidateData()
}
fun updateUserInfo(
@@ -337,7 +334,7 @@ class User(
}
flowSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
liveSet?.metadata?.invalidateData()
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
@@ -484,36 +481,18 @@ class UserFlowSet(
class UserLiveSet(
u: User,
) {
val innerMetadata = UserBundledRefresherLiveData(u)
val metadata = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val innerFollows = UserBundledRefresherLiveData(u)
val innerFollowers = UserBundledRefresherLiveData(u)
val innerReports = UserBundledRefresherLiveData(u)
val innerMessages = UserBundledRefresherLiveData(u)
val innerRelays = UserBundledRefresherLiveData(u)
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val metadata = innerMetadata.map { it }
val follows = innerFollows.map { it }
val followers = innerFollowers.map { it }
val reports = innerReports.map { it }
val messages = innerMessages.map { it }
val relays = innerRelays.map { it }
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged()
val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged()
val follows = UserBundledRefresherLiveData(u)
val followers = UserBundledRefresherLiveData(u)
val reports = UserBundledRefresherLiveData(u)
val messages = UserBundledRefresherLiveData(u)
val relays = UserBundledRefresherLiveData(u)
val relayInfo = UserBundledRefresherLiveData(u)
val zaps = UserBundledRefresherLiveData(u)
val bookmarks = UserBundledRefresherLiveData(u)
val statuses = UserBundledRefresherLiveData(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
@@ -525,22 +504,19 @@ class UserLiveSet(
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
statuses.hasObservers()
fun destroy() {
innerMetadata.destroy()
innerFollows.destroy()
innerFollowers.destroy()
innerReports.destroy()
innerMessages.destroy()
innerRelays.destroy()
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerStatuses.destroy()
metadata.destroy()
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
zaps.destroy()
bookmarks.destroy()
statuses.destroy()
}
}
@@ -570,13 +546,6 @@ class UserBundledRefresherLiveData(
postValue(UserState(user))
}
}
fun <Y> map(transform: (UserState) -> Y): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
@Stable
@@ -602,21 +571,6 @@ class UserBundledRefresherFlow(
}
}
class UserLoadingLiveData<Y>(
val user: User,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)
}
override fun onInactive() {
super.onInactive()
NostrSingleUserDataSource.remove(user)
}
}
@Immutable class UserState(
val user: User,
)
@@ -1,514 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.utils.TimeUtils
// TODO: Migrate this to a property of AccountVi
object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
lateinit var account: Account
var otherAccounts = listOf<HexKey>()
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountMetadataFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = listOf(account.userProfile().pubkeyHex),
limit = 20,
),
)
fun createOtherAccountsBaseFilter(): TypedFilter? {
val otherAuthors = otherAccounts.filter { it != account.userProfile().pubkeyHex }
if (otherAuthors.isEmpty()) return null
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAuthors,
limit = otherAuthors.size * 20,
),
)
}
fun createAccountSettingsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
)
fun createAccountSettings2Filter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
limit = 1,
),
)
fun createAccountReportsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList,
),
)
fun createAccountLastPostsListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
)
fun createNotificationFilter(): TypedFilter {
var since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?.toMutableMap()
if (since == null) {
since =
account.connectToRelays.value
.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
.toMutableMap()
} else {
account.connectToRelays.value.forEach {
val eose = since.get(it.url)
if (eose == null) {
since.put(it.url, EOSETime(TimeUtils.oneWeekAgo()))
}
}
}
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
PollNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = since,
),
)
}
fun createNotificationFilter2(): TypedFilter {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
since = since,
),
)
}
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get("&&((GIFTWRAPS_EOSE))&&")
?.relayList
?.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
)
val accountChannel =
requestNewChannel { time, relayUrl ->
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.settings.defaultNotificationFollowList.value,
relayUrl,
time,
)
latestEOSEs.addOrUpdate(
account.userProfile(),
"&&((GIFTWRAPS_EOSE))&&",
relayUrl,
time,
)
} else {
hasLoadedTheBasics[account.userProfile()] = true
invalidateFilters()
}
}
override fun consume(
event: Event,
relay: Relay,
) {
if (LocalCache.justVerify(event)) {
consumeAlreadyVerified(event, relay)
}
}
fun consumeAlreadyVerified(
event: Event,
relay: Relay,
) {
checkNotInMainThread()
when (event) {
is OtsEvent -> {
// verifies new OTS upon arrival
Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver)
}
is PrivateOutboxRelayListEvent -> {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent == null || event.createdAt > noteEvent.createdAt) {
event.privateTags(account.signer) {
LocalCache.justConsume(event, relay)
}
}
}
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
if (!event.isDeleted()) {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent != null) {
if (event.createdAt > noteEvent.createdAt || relay.brief !in note.relays) {
LocalCache.consume(event, relay)
}
} else {
// decrypts
event.cachedDraft(account.signer) {}
LocalCache.justConsume(event, relay)
}
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
noteEvent.innerEventId?.let {
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
this.consumeAlreadyVerified(it, relay)
}
} ?: run {
event.unwrap(account.signer) {
this.consume(it, relay)
noteEvent.innerEventId = it.id
}
}
}
} else {
// new event
event.unwrap(account.signer) {
LocalCache.justConsume(event, relay)
this.consume(it, relay)
}
}
}
is SealedRumorEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? SealedRumorEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
noteEvent.innerEventId?.let {
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
LocalCache.justConsume(it, relay)
}
} ?: run {
event.unseal(account.signer) {
LocalCache.justConsume(it, relay)
noteEvent.innerEventId = it.id
}
}
}
} else {
// new event
event.unseal(account.signer) {
LocalCache.justConsume(event, relay)
LocalCache.justConsume(it, relay)
}
}
}
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event == null) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
LocalCache.justConsume(event, relay)
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
override fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
checkNotInMainThread()
super.markAsSeenOnRelay(eventId, relay)
val note = LocalCache.getNoteIfExists(eventId) ?: return
val noteEvent = note.event ?: return
markInnerAsSeenOnRelay(noteEvent, relay)
}
private fun markInnerAsSeenOnRelay(
newNoteEvent: Event,
relay: Relay,
) {
markInnerAsSeenOnRelay(newNoteEvent.id, relay)
}
private fun markInnerAsSeenOnRelay(
eventId: HexKey,
relay: Relay,
) {
val note = LocalCache.getNoteIfExists(eventId)
if (note != null) {
note.addRelay(relay)
val noteEvent = note.event
if (noteEvent is GiftWrapEvent) {
noteEvent.innerEventId?.let {
markInnerAsSeenOnRelay(it, relay)
}
} else if (noteEvent is SealedRumorEvent) {
noteEvent.innerEventId?.let {
markInnerAsSeenOnRelay(it, relay)
}
}
}
}
override fun updateChannelFilters() =
if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everything about the user logged in
accountChannel.typedFilters =
listOfNotNull(
createAccountMetadataFilter(),
createAccountSettings2Filter(),
createNotificationFilter(),
createNotificationFilter2(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
).ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters =
listOf(
createAccountMetadataFilter(),
createAccountSettingsFilter(),
createAccountSettings2Filter(),
).ifEmpty { null }
}
override fun auth(
relay: Relay,
challenge: String,
) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
account.sendAuthEvent(relay, challenge)
}
}
override fun notify(
relay: Relay,
description: String,
) {
super.notify(relay, description)
if (this::account.isInitialized) {
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description))
}
}
}
@@ -1,118 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(
account: Account,
channel: Channel,
) {
this.account = account
this.channel = channel
resetFilters()
}
fun clear() {
account = null
channel = null
}
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
}
return null
}
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.typedFilters =
listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter(),
).ifEmpty { null }
}
}
@@ -1,108 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
lateinit var account: Account
private var withRoom: ChatroomKey? = null
private val latestEOSEs = EOSEAccount()
fun loadMessagesBetween(
accountIn: Account,
withRoom: ChatroomKey,
) {
this.account = accountIn
this.withRoom = withRoom
resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(withRoom.hashCode().toString())
?.relayList,
),
)
} else {
null
}
}
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(withRoom.hashCode().toString())
?.relayList,
),
)
} else {
null
}
}
fun clearEOSEs(account: Account) {
latestEOSEs.removeDataFor(account.userProfile())
}
val inandoutChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters =
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
).ifEmpty { null }
}
}
@@ -1,71 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null
private fun createLoadCommunityFilter(): TypedFilter? {
val myCommunityToWatch = communityToWatch ?: return null
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
val authors =
community
.moderators()
.map { it.pubKey }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex))
if (authors.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = authors,
tags =
mapOf(
"a" to listOf(myCommunityToWatch.address.toValue()),
),
kinds = listOf(CommunityPostApprovalEvent.KIND),
limit = 500,
),
)
}
val loadCommunityChannel = requestNewChannel()
override fun updateChannelFilters() {
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
}
fun loadCommunity(note: AddressableNote?) {
communityToWatch = note
invalidateFilters()
}
}
@@ -1,455 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
var job: Job? = null
override fun start() {
job?.cancel()
job =
scope.launch(Dispatchers.IO) {
account.liveDiscoveryFollowLists.collect {
if (this@NostrDiscoveryDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
?.ifEmpty { null }
val geohashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"t" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"g" to it,
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createNIP89Filter(kTags: List<String>): List<TypedFilter> =
listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
)
fun createLiveStreamFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.authors
?.toList()
?.ifEmpty { null }
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
return listOfNotNull(
TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = followsRelays,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
follows?.let {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
if (followChats.isNotEmpty()) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
} else {
null
},
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags = mapOf("g" to hashToLoad),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf("g" to hashToLoad),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags = mapOf("g" to hashToLoad),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
val discoveryFeedChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.settings.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createNIP89Filter(listOf("5300")))
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter(),
),
).toList()
.ifEmpty { null }
}
}
@@ -1,232 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFeed") {
private var searchString: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString
if (mySearchString.isNullOrBlank()) {
return null
}
val hexToWatch =
try {
val isAStraightHex =
if (Hex.isHex(mySearchString)) {
Hex.decode(mySearchString).toHexKey()
} else {
null
}
when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) {
is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey()
is NPub -> parsed.hex
is NProfile -> parsed.hex
is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> parsed.hex
is NEvent -> parsed.hex
is NEmbed -> parsed.event.id
is NRelay -> null
is NAddress -> parsed.aTag()
else -> isAStraightHex
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
val directReferenceFilters =
hexToWatch?.let {
if (it.contains(":")) {
// naddr
listOfNotNull(
ATag.parse(it, null)?.let { aTag ->
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
limit = 5,
),
)
},
)
} else {
// event ids
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = listOfNotNull(it),
),
),
// authors
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(it),
// just to be sure
limit = 5,
),
),
)
}
} ?: emptyList()
// downloads all the reactions to a given event.
return directReferenceFilters +
listOfNotNull(
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
)
}
val searchChannel = requestNewChannel()
override fun updateChannelFilters() {
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(searchString: String) {
if (this.searchString != searchString) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
this.searchString = searchString
invalidateFilters()
}
}
fun clear() {
if (searchString != null) {
println("DataSource: ${this.javaClass.simpleName} Clear")
searchString = null
invalidateFilters()
}
}
}
@@ -1,102 +0,0 @@
/**
* Copyright (c) 2024 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.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter> {
val threadToLoad = eventToWatch ?: return emptyList()
val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList()
val eventsToLoad =
branch.allNotes
.filter { it.event == null }
.map { it.idHex }
.toSet()
.ifEmpty { null }
val address = if (branch.root is AddressableNote) branch.root.idHex else null
val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id
return listOfNotNull(
eventsToLoad?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = it.toList(),
),
)
},
event?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"e" to listOf(event),
),
),
)
},
address?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"a" to listOf(address),
),
),
)
},
)
}
val loadEventsChannel =
requestNewChannel { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter()
}
fun loadThread(noteId: String?) {
if (eventToWatch != noteId) {
eventToWatch = noteId
invalidateFilters()
}
}
}
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
@@ -374,7 +373,7 @@ class ZapPaymentHandler(
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: splitSetup.mainId(),
toUser?.toBestDisplayName() ?: splitSetup.mainId(),
),
null,
)
@@ -25,7 +25,6 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.util.Log
import com.vitorpamplona.ammolite.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
@@ -49,7 +48,6 @@ class ConnectivityFlow(
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
checkNotInMainThread()
Log.d("ConnectivityFlow", "onAvailable ${network.networkHandle}")
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData()))
@@ -61,7 +59,6 @@ class ConnectivityFlow(
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
checkNotInMainThread()
val isMobile = networkCapabilities.isMeteredOrMobileData()
Log.d("ConnectivityFlow", "onCapabilitiesChanged ${network.networkHandle} $isMobile")
trySend(ConnectivityStatus.Active(network.networkHandle, isMobile))
@@ -77,7 +74,6 @@ class ConnectivityFlow(
}
awaitClose {
checkNotInMainThread()
Log.d("ConnectivityFlow", "Stopping Connectivity Flow")
connectivityManager.unregisterNetworkCallback(networkCallback)
trySend(ConnectivityStatus.Off)
@@ -21,38 +21,44 @@
package com.vitorpamplona.amethyst.service.eventCache
import android.util.Log
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import java.util.concurrent.atomic.AtomicBoolean
class MemoryTrimmingService {
class MemoryTrimmingService(
val cache: LocalCache,
) {
var isTrimmingMemoryMutex = AtomicBoolean(false)
private suspend fun doTrim(account: Account?) {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
private suspend fun doTrim(
account: Account? = null,
otherAccounts: List<AccountInfo>,
) {
cache.cleanObservers()
account?.let {
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneOldAndHiddenMessages(it)
NostrChatroomDataSource.clearEOSEs(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
}
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneContactLists(accounts)
cache.pruneRepliesAndReactions(accounts)
cache.prunePastVersionsOfReplaceables()
cache.pruneExpiredEvents()
}
suspend fun run(account: Account?) {
suspend fun run(
account: Account?,
otherAccounts: List<AccountInfo>,
) {
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
Log.d("ServiceManager", "Trimming Memory")
try {
doTrim(account)
doTrim(account, otherAccounts)
} finally {
isTrimmingMemoryMutex.getAndSet(false)
}
@@ -32,6 +32,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.size.Precision
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import com.vitorpamplona.amethyst.isDebug
import okhttp3.Call
class ImageLoaderSetup {
@@ -40,7 +41,6 @@ class ImageLoaderSetup {
app: Application,
diskCache: DiskCache,
memoryCache: MemoryCache,
isDebug: Boolean,
callFactory: () -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
@@ -24,26 +24,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Copyright (c) 2024 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.
*/
object BackgroundMedia {
// background playing mutex.
val bgInstance = MutableStateFlow<MediaControllerState?>(null)
@@ -29,26 +29,6 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
import com.vitorpamplona.amethyst.Amethyst
/**
* Copyright (c) 2024 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.
*/
class SimultaneousPlaybackCalculator {
companion object {
fun isLowMemory(context: Context): Boolean {
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2024 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.service.relayClient
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.datasources.EventCollector
import com.vitorpamplona.ammolite.relays.datasources.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
class CacheClientConnector(
val client: NostrClient,
val cache: LocalCache,
) {
val receiver =
EventCollector(client) { event, relay ->
cache.verifyAndConsume(event, relay)
}
val confirmationWatcher =
RelayInsertConfirmationCollector(client) { eventId, relay ->
cache.markAsSeen(eventId, relay)
unwrapAndMarkAsSeen(eventId, relay)
}
fun destroy() {
receiver.destroy()
confirmationWatcher.destroy()
}
private fun unwrapAndMarkAsSeen(
eventId: HexKey,
relay: Relay,
) {
val note = LocalCache.getNoteIfExists(eventId)
if (note != null) {
note.addRelay(relay)
val noteEvent = note.event
if (noteEvent is GiftWrapEvent) {
noteEvent.innerEventId?.let {
unwrapAndMarkAsSeen(it, relay)
}
} else if (noteEvent is SealedRumorEvent) {
noteEvent.innerEventId?.let {
unwrapAndMarkAsSeen(it, relay)
}
}
}
}
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2024 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.service.relayClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQueryState
@Composable
fun <T> KeyDataSourceSubscription(
state: T,
dataSource: QueryBasedSubscriptionOrchestrator<T>,
) {
DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
}
@Composable
fun <T : MutableQueryState> KeyDataSourceSubscription(
state: T,
dataSource: MutableQueryBasedSubscriptionOrchestrator<T>,
) {
DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
}
@@ -18,29 +18,35 @@
* 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.chats.rooms
package com.vitorpamplona.amethyst.service.relayClient.authCommand.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun WatchLifecycleAndRefreshDataSource(accountViewModel: AccountViewModel) {
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.account = accountViewModel.account
NostrChatroomListDataSource.start()
}
}
fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator)
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
@Composable
fun RelayAuthSubscription(
accountViewModel: AccountViewModel,
dataSource: AuthCoordinator,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(accountViewModel) {
ScreenAuthAccount(accountViewModel.account)
}
DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
}
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2024 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.service.relayClient.authCommand.model
import android.util.Log
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.RelayAuthenticator
import kotlin.collections.forEach
class ScreenAuthAccount(
val account: Account,
)
class AuthCoordinator(
client: NostrClient,
) {
private val authWithAccounts = ListWithUniqueSetCache<ScreenAuthAccount, Account> { it.account }
val receiver =
RelayAuthenticator(client) { challenge, relay ->
authWithAccounts.distinct().forEach {
it.sendAuthEvent(relay, challenge)
}
}
fun destroy() {
receiver.destroy()
}
// This is called by main. Keep it really fast.
fun subscribe(account: ScreenAuthAccount?) {
if (account == null) return
if (isDebug) {
Log.d(this::class.simpleName, "Watch $account")
}
authWithAccounts.add(account)
}
// This is called by main. Keep it really fast.
fun unsubscribe(account: ScreenAuthAccount?) {
if (account == null) return
if (isDebug) {
Log.d(this::class.simpleName, "Unwatch $account")
}
authWithAccounts.remove(account)
}
}
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2024 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.service.relayClient.authCommand.model
import androidx.lifecycle.AtomicReference
class ListWithUniqueSetCache<T, U>(
val key: (T) -> U,
) {
private val list = AtomicReference(listOf<T>())
private val cacheSet = AtomicReference<Set<U>?>(setOf<U>())
fun isEmpty() = list.get().isEmpty()
fun add(item: T) = set(list.get() + item)
fun remove(item: T) = set(list.get() - item)
fun set(newList: List<T>) {
list.set(newList)
// Invalidate the cache - next read will recompute
cacheSet.set(null)
}
fun distinct(): Set<U> {
var currentSet = cacheSet.get()
// Check if the cached set is based on the current list
if (currentSet != null) {
return currentSet
}
// Compute and attempt to atomically update the cache
val newSet = list.get().mapTo(mutableSetOf<U>(), key)
cacheSet.compareAndSet(currentSet, newSet)
return newSet
}
fun forEachSubscriber(action: (T) -> Unit) {
list.get().forEach(action)
}
fun forEachUniqueSubscriber(action: (U) -> Unit) {
distinct().forEach(action)
}
}
@@ -18,12 +18,13 @@
* 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.components
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NotifyRequestDialog
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -33,11 +34,17 @@ import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
fun DisplayNotifyMessages(
accountViewModel: AccountViewModel,
nav: INav,
) {
val openDialogMsg =
accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null)
) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav)
openDialogMsg.value?.firstOrNull()?.let { request ->
@Composable
fun DisplayNotifyMessages(
requests: NotifyRequestsCache,
accountViewModel: AccountViewModel,
nav: INav,
) {
val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle(emptySet())
openDialogMsg.value.firstOrNull()?.let { request ->
NotifyRequestDialog(
title =
stringRes(
@@ -47,8 +54,7 @@ fun DisplayNotifyMessages(
textContent = request.description,
accountViewModel = accountViewModel,
nav = nav,
) {
accountViewModel.dismissPaymentRequest(request)
}
onDismiss = { requests.dismissPaymentRequest(request) },
)
}
}
@@ -18,7 +18,7 @@
* 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.actions
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2024 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.service.relayClient.notifyCommand.model
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.RelayNotifier
class NotifyCoordinator(
client: NostrClient,
) {
val requests = NotifyRequestsCache()
val receiver =
RelayNotifier(client) { message, relay ->
requests.addPaymentRequestIfNew(message, relay.url)
}
fun destroy() {
receiver.destroy()
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2024 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.service.relayClient.notifyCommand.model
data class NotifyRequest(
val relayUrl: String,
val description: String,
)
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.service.relayClient.notifyCommand.model
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class NotifyRequestsCache {
val transientPaymentRequestDismissals: MutableStateFlow<Set<NotifyRequest>> = MutableStateFlow(emptySet())
val transientPaymentRequests: MutableStateFlow<Set<NotifyRequest>> = MutableStateFlow(emptySet())
fun addPaymentRequestIfNew(
description: String,
relayUrl: String,
) {
addPaymentRequestIfNew(NotifyRequest(description, relayUrl))
}
fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) {
if (
!this.transientPaymentRequests.value.contains(paymentRequest) &&
!this.transientPaymentRequestDismissals.value.contains(paymentRequest)
) {
this.transientPaymentRequests.value += paymentRequest
}
}
fun dismissPaymentRequest(request: NotifyRequest) {
if (this.transientPaymentRequests.value.contains(request)) {
this.transientPaymentRequests.update { it - request }
this.transientPaymentRequestDismissals.update { it + request }
}
}
}
@@ -0,0 +1,83 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand
import android.util.Log
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator
import java.util.concurrent.ConcurrentHashMap
/**
* Creates a data source where the filters are derived from
* data (keys) that is being watched in the UI.
*/
abstract class QueryBasedSubscriptionOrchestrator<T>(
client: NostrClient,
) : SubscriptionOrchestrator(client) {
private var queries: ConcurrentHashMap<T, T> = ConcurrentHashMap()
// This is called by main. Keep it really fast.
fun subscribe(query: T?) {
if (query == null) return
val wasEmpty = queries.isEmpty()
queries.put(query, query)
if (wasEmpty) {
start()
}
invalidateFilters()
if (isDebug) {
Log.d(this::class.simpleName, "Watch $query (${queries.size} queries)")
}
}
// This is called by main. Keep it really fast.
fun unsubscribe(query: T?) {
if (query == null) return
queries.remove(query)
invalidateFilters()
if (queries.isEmpty()) {
stop()
}
if (isDebug) {
Log.d(this::class.simpleName, "Unwatch $query (${queries.size} queries)")
}
}
fun forEachSubscriber(action: (T) -> Unit) {
queries.keys.forEach(action)
}
final override fun updateSubscriptions() {
updateSubscriptions(queries.keys)
}
abstract fun updateSubscriptions(keys: Set<T>)
}
@@ -0,0 +1,107 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.ammolite.relays.NostrClient
import kotlinx.coroutines.CoroutineScope
class RelaySubscriptionsCoordinator(
cache: LocalCache,
client: NostrClient,
scope: CoroutineScope,
) {
// main one: notifications, dms and account settings
val account = AccountFilterAssembler(client)
// always running, feed assemblers.
val home = HomeFilterAssembler(client)
val chatroomList = ChatroomListFilterAssembler(client)
val video = VideoFilterAssembler(client)
val discovery = DiscoveryFilterAssembler(client)
// loaders of content that is not yet in the device.
// they are active when looking at events, users, channels.
val channelFinder = ChannelFinderFilterAssembler(client)
val eventFinder = EventFinderFilterAssembler(client)
val userFinder = UserFinderFilterAssembler(client)
// active when searching or tagging users.
val search = SearchFilterAssembler(cache, client, scope)
// active depending on the screen.
val channel = ChannelFilterAssembler(client)
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val thread = ThreadFilterAssembler(client)
val profile = UserProfileFilterAssembler(client)
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
val all =
listOf(
account,
home,
chatroomList,
video,
discovery,
channelFinder,
eventFinder,
userFinder,
search,
channel,
chatroom,
community,
thread,
profile,
hashtags,
geohashes,
nwc,
)
fun start() = all.forEach { it.start() }
fun stop() = all.forEach { it.stop() }
fun destroy() = all.forEach { it.destroy() }
fun printCounters() = all.forEach { it.stats.printCounter() }
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.vitorpamplona.amethyst.Amethyst
@Composable
fun RelaySubscriptionsCoordinatorSubscription() = RelaySubscriptionsCoordinatorSubscription(Amethyst.instance.sources)
@Composable
fun RelaySubscriptionsCoordinatorSubscription(dataSource: RelaySubscriptionsCoordinator) {
DisposableEffect(Unit) {
dataSource.start()
onDispose {
dataSource.stop()
}
}
}
@@ -0,0 +1,299 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relays.EOSERelayList
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.utils.TimeUtils
// This allows multiple screen to be listening to tags, even the same tag
class AccountQueryState(
val account: Account,
val otherAccounts: Set<HexKey>,
)
class AccountFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<AccountQueryState>(client) {
val latestEOSE = EOSERelayList()
var hasLoadedTheBasics: Boolean = false
fun createAccountMetadataFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = authorsHexes,
limit = 20 * authorsHexes.size,
),
)
fun createOtherAccountsBaseFilter(otherAccounts: List<HexKey>): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAccounts,
limit = otherAccounts.size * 20,
),
)
}
fun createAccountSettingsFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
PeopleListEvent.KIND,
MuteListEvent.KIND,
BadgeProfilesEvent.KIND,
EmojiPackSelectionEvent.KIND,
),
authors = authorsHexes,
limit = 100 * authorsHexes.size,
),
)
fun createAccountSettings2Filter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
AppSpecificDataEvent.KIND,
),
authors = authorsHexes,
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
limit = 2 * authorsHexes.size,
),
)
fun createAccountLastPostsListFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = authorsHexes,
limit = 500,
),
)
fun createAccountReportsAndDraftsFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DraftEvent.KIND,
ReportEvent.KIND,
BookmarkListEvent.KIND,
),
authors = authorsHexes,
since = latestEOSE.relayList,
),
)
fun createGiftWrapsToMeFilter(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to authorsHexes),
since =
latestEOSE.relayList.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
)
fun createNotificationFilter(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
PollNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
limit = 4000,
since = latestEOSE.relayList,
),
)
fun createNotificationFilter2(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
limit = 400,
since = latestEOSE.relayList,
),
)
fun mergeAllFilters(
mainAccounts: List<HexKey>,
otherAccounts: List<HexKey>,
): List<TypedFilter>? =
if (hasLoadedTheBasics) {
// gets everything about the user logged in
listOfNotNull(
createAccountMetadataFilter(mainAccounts),
createAccountSettings2Filter(mainAccounts),
createNotificationFilter(mainAccounts),
createNotificationFilter2(mainAccounts),
createGiftWrapsToMeFilter(mainAccounts),
createAccountReportsAndDraftsFilter(mainAccounts),
createAccountSettingsFilter(mainAccounts),
createAccountLastPostsListFilter(mainAccounts),
createOtherAccountsBaseFilter(otherAccounts),
).ifEmpty { null }
} else {
// just the basics.
listOf(
createAccountMetadataFilter(mainAccounts),
createAccountSettingsFilter(mainAccounts),
createAccountSettings2Filter(mainAccounts),
).ifEmpty { null }
}
val accountChannel =
requestNewSubscription { time, relayUrl ->
if (hasLoadedTheBasics) {
latestEOSE.addOrUpdate(relayUrl, time)
} else {
hasLoadedTheBasics = true
invalidateFilters()
}
}
// One sub per subscribed account
override fun updateSubscriptions(keys: Set<AccountQueryState>) {
val mainAccounts = mutableSetOf<HexKey>()
val otherAccounts = mutableSetOf<HexKey>()
keys.forEach {
mainAccounts.add(it.account.userProfile().pubkeyHex)
otherAccounts.addAll(it.otherAccounts)
}
accountChannel.typedFilters = mergeAllFilters(mainAccounts.toList(), (otherAccounts - mainAccounts).toList())
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun AccountFilterAssemblerSubscription(accountViewModel: AccountViewModel) = AccountFilterAssemblerSubscription(accountViewModel, Amethyst.instance.sources.account)
@Composable
fun AccountFilterAssemblerSubscription(
accountViewModel: AccountViewModel,
dataSource: AccountFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(accountViewModel) {
AccountQueryState(accountViewModel.account, accountViewModel.allAccountsSync().toSet())
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -18,23 +18,32 @@
* 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.service
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import kotlin.collections.filter
import kotlin.collections.mapNotNull
object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<Channel>()
// This allows multiple screen to be listening to tags, even the same tag
class ChannelFinderQueryState(
val channel: Channel,
)
private fun createMetadataChangeFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
class ChannelFinderFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<ChannelFinderQueryState>(client) {
private fun createMetadataChangeFilter(keys: Set<ChannelFinderQueryState>): TypedFilter? {
val reactionsToWatch = keys.filter { it.channel is PublicChatChannel }.map { it.channel.idHex }
if (reactionsToWatch.isEmpty()) {
return null
@@ -51,11 +60,11 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
)
}
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
fun createLoadEventsIfNotLoadedFilter(keys: Set<ChannelFinderQueryState>): TypedFilter? {
val directEventsToLoad =
channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel }
keys.filter { it.channel.notes.isEmpty() && it.channel is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
val interestedEvents = (directEventsToLoad).map { it.channel.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
@@ -72,11 +81,17 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
fun createLoadStreamingIfNotLoadedFilter(keys: Set<ChannelFinderQueryState>): List<TypedFilter>? {
val directEventsToLoad =
channelsToWatch.filterIsInstance<LiveActivitiesChannel>().filter { it.info == null }
keys.mapNotNull {
if (it.channel is LiveActivitiesChannel && it.channel.info == null) {
it.channel
} else {
null
}
}
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
val interestedEvents = directEventsToLoad.map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
@@ -98,28 +113,13 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
}
}
val singleChannelChannel = requestNewChannel()
val singleChannelChannel = requestNewSubscription()
override fun updateChannelFilters() {
val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
override fun updateSubscriptions(keys: Set<ChannelFinderQueryState>) {
val reactions = createMetadataChangeFilter(keys)
val missing = createLoadEventsIfNotLoadedFilter(keys)
val missingStreaming = createLoadStreamingIfNotLoadedFilter(keys)
singleChannelChannel.typedFilters =
((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null }
}
fun add(eventId: Channel) {
if (eventId !in channelsToWatch) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Channel) {
if (eventId in channelsToWatch) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
singleChannelChannel.typedFilters = ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null }
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
@Composable
fun ChannelFinderFilterAssemblerSubscription(channel: Channel) = ChannelFinderFilterAssemblerSubscription(channel, Amethyst.instance.sources.channelFinder)
@Composable
fun ChannelFinderFilterAssemblerSubscription(
channel: Channel,
dataSource: ChannelFinderFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(channel) {
ChannelFinderQueryState(channel)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChannelState
@Composable
fun observeChannel(baseChannel: Channel): State<ChannelState?> {
ChannelFinderFilterAssemblerSubscription(baseChannel)
return baseChannel.live.observeAsState()
}
@@ -18,12 +18,15 @@
* 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.service
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
@@ -43,26 +46,22 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import kotlin.collections.mapNotNullTo
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
private var nextEventsToWatch = setOf<Note>()
private var nextAddressesToWatch = setOf<Note>()
// This allows multiple screen to be listening to tags, even the same tag
class EventFinderQueryState(
val note: Note,
)
private var eventsToWatchInProd = setOf<Note>()
private var addressesToWatchInProd = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val myAddressesToWatch =
(
eventsToWatchInProd.filter { it.address() != null } +
addressesToWatchInProd.filter { it.address() != null }
).toSet()
if (myAddressesToWatch.isEmpty()) {
class EventFinderFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<EventFinderQueryState>(client) {
private fun createReactionsToWatchInAddressFilter(keys: Set<AddressableNote>): List<TypedFilter>? {
if (keys.isEmpty()) {
return null
}
return groupByEOSEPresence(myAddressesToWatch)
return groupByEOSEPresence(keys)
.map {
listOf(
TypedFilter(
@@ -105,15 +104,15 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}.flatten()
}
private fun createAddressFilter(): List<TypedFilter>? {
val myAddressesToWatch = addressesToWatchInProd.filter { it.event == null }
private fun createAddressFilter(keys: Set<AddressableNote>): List<TypedFilter>? {
val myAddressesToWatch = keys.filter { it.event == null }
if (myAddressesToWatch.isEmpty()) {
return null
}
return myAddressesToWatch.mapNotNull {
it.address()?.let { aTag ->
return myAddressesToWatch.map {
it.address().let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = EVENT_FINDER_TYPES,
@@ -140,12 +139,12 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
if (eventsToWatchInProd.isEmpty()) {
private fun createRepliesAndReactionsFilter(keys: Set<Note>): List<TypedFilter>? {
if (keys.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatchInProd)
return groupByEOSEPresence(keys)
.map {
listOf(
TypedFilter(
@@ -191,12 +190,8 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}.flatten()
}
private fun createQuotesFilter(): List<TypedFilter>? {
if (eventsToWatchInProd.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatchInProd)
private fun createQuotesFilter(keys: Set<Note>): List<TypedFilter>? =
groupByEOSEPresence(keys)
.map {
listOf(
TypedFilter(
@@ -212,18 +207,17 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
),
)
}.flatten()
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatchInProd.filter { it.event == null }
fun createLoadEventsIfNotLoadedFilter(keys: Set<Note>): List<TypedFilter>? {
val directEventsToLoad = keys.filter { it.event == null }
val threadingEventsToLoad =
eventsToWatchInProd
keys
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet()
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet().toList()
if (interestedEvents.isEmpty()) {
return null
@@ -233,34 +227,22 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
ids = interestedEvents.toList(),
),
filter = SincePerRelayFilter(ids = interestedEvents),
),
)
}
val singleEventChannel =
requestNewChannel { time, relayUrl ->
requestNewSubscription { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (changingFilters.get()) return@requestNewChannel
if (isUpdatingFilters()) return@requestNewSubscription
checkNotInMainThread()
eventsToWatchInProd.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
forEachSubscriber {
val eose = it.note.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
addressesToWatchInProd.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
it.note.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
@@ -271,46 +253,33 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
invalidateFilters()
}
override fun updateChannelFilters() {
addressesToWatchInProd = nextAddressesToWatch
eventsToWatchInProd = nextEventsToWatch
override fun updateSubscriptions(keys: Set<EventFinderQueryState>) {
val addressables =
keys
.mapNotNullTo(mutableSetOf()) {
it.note as? AddressableNote
}.toSet()
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
val quotes = createQuotesFilter()
val events =
keys
.mapNotNullTo(mutableSetOf()) {
if (it.note !is AddressableNote) {
it.note
} else {
null
}
}.toSet()
val eventReactions = createRepliesAndReactionsFilter(events)
val addressReactions = createReactionsToWatchInAddressFilter(addressables)
val missingEvents = createLoadEventsIfNotLoadedFilter(events)
val missingAddresses = createAddressFilter(addressables)
val quotes = createQuotesFilter(events + addressables)
singleEventChannel.typedFilters =
listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null }
}
fun add(eventId: Note) {
if (!nextEventsToWatch.contains(eventId)) {
nextEventsToWatch = nextEventsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Note) {
if (nextEventsToWatch.contains(eventId)) {
nextEventsToWatch = nextEventsToWatch.minus(eventId)
invalidateFilters()
}
}
fun addAddress(addressableNote: Note) {
if (!nextAddressesToWatch.contains(addressableNote)) {
nextAddressesToWatch = nextAddressesToWatch.plus(addressableNote)
invalidateFilters()
}
}
fun removeAddress(addressableNote: Note) {
if (nextAddressesToWatch.contains(addressableNote)) {
nextAddressesToWatch = nextAddressesToWatch.minus(addressableNote)
invalidateFilters()
}
listOfNotNull(missingEvents, missingAddresses, eventReactions, addressReactions, quotes).flatten().ifEmpty { null }
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
@Composable
fun EventFinderFilterAssemblerSubscription(note: Note) = EventFinderFilterAssemblerSubscription(note, Amethyst.instance.sources.eventFinder)
@Composable
fun EventFinderFilterAssemblerSubscription(
note: Note,
dataSource: EventFinderFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(note) {
EventFinderQueryState(note)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,239 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.quartz.nip01Core.core.Event
@Composable
fun observeNote(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().metadata.observeAsState()
}
@Composable
fun <T : Event> observeNoteEvent(note: Note): State<T?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { it.note.event as? T? }
.observeAsState(note.event as? T?)
}
@Composable
fun <T> observeNoteAndMap(
note: Note,
map: (Note) -> T,
): State<T> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { map(it.note) }
.distinctUntilChanged()
.observeAsState(map(note))
}
@Composable
fun <T, U> observeNoteEventAndMap(
note: Note,
map: (T) -> U,
): State<U?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { (it.note.event as? T)?.let { map(it) } }
.distinctUntilChanged()
.observeAsState(
(note.event as? T)?.let { map(it) },
)
}
@Composable
fun observeNoteHasEvent(note: Note): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { it.note.event != null }
.distinctUntilChanged()
.observeAsState(note.event != null)
}
@Composable
fun observeNoteReplies(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().replies.observeAsState()
}
@Composable
fun observeNoteReplyCount(note: Note): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.replies
.map { it.note.reactions.size }
.distinctUntilChanged()
.observeAsState(note.reactions.size)
}
@Composable
fun observeNoteReactions(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().reactions.observeAsState()
}
@Composable
fun observeNoteReactionCount(note: Note): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.reactions
.map {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}.distinctUntilChanged()
.observeAsState(0)
}
@Composable
fun observeNoteZaps(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().zaps.observeAsState()
}
@Composable
fun observeNoteReposts(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().boosts.observeAsState()
}
@Composable
fun observeNoteRepostsBy(
note: Note,
user: User,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.boosts
.map { it.note.isBoostedBy(user) }
.distinctUntilChanged()
.observeAsState(note.isBoostedBy(user))
}
@Composable
fun observeNoteRepostCount(note: Note): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.boosts
.map { it.note.boosts.size }
.distinctUntilChanged()
.observeAsState(note.boosts.size)
}
@Composable
fun observeNoteReferences(note: Note): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.zaps
.combineWith(note.live().boosts, note.live().reactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() == true ||
boostState?.note?.boosts?.isNotEmpty() == true ||
reactionState?.note?.reactions?.isNotEmpty() == true
}.distinctUntilChanged()
.observeAsState(
note.zaps.isNotEmpty() || note.boosts.isNotEmpty() || note.reactions.isNotEmpty(),
)
}
@Composable
fun observeNoteOts(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().ots.observeAsState()
}
@Composable
fun observeNoteEdits(note: Note): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().edits.observeAsState()
}
@@ -18,45 +18,44 @@
* 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.service
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigner: NostrSigner,
) : AmethystNostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
// This allows multiple screen to be listening to tags, even the same tag
class NWCPaymentQueryState(
val fromServiceHex: String,
val toUserHex: String,
val replyingToHex: String,
)
private fun createWalletConnectServiceWatcher(): TypedFilter {
// downloads all the reactions to a given event.
return TypedFilter(
types = feedTypes,
class NWCPaymentFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<NWCPaymentQueryState>(client) {
fun createAccountMetadataFilter(keys: Set<NWCPaymentQueryState>): TypedFilter =
TypedFilter(
types = setOf(FeedType.WALLET_CONNECT),
filter =
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
authors = keys.map { it.fromServiceHex },
tags =
mapOf(
"e" to listOf(replyingToHex),
"p" to listOf(toUserHex),
"e" to keys.map { it.replyingToHex },
"p" to keys.map { it.toUserHex },
),
limit = 1,
),
)
}
val channel = requestNewChannel()
val channel = requestNewSubscription()
override fun updateChannelFilters() {
val wc = createWalletConnectServiceWatcher()
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
override fun updateSubscriptions(keys: Set<NWCPaymentQueryState>) {
channel.typedFilters = listOf(createAccountMetadataFilter(keys))
}
}
@@ -18,10 +18,14 @@
* 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.service
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.findMinimumEOSEsForUsers
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.groupByEOSEPresence
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
@@ -32,13 +36,18 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
private var usersToWatch = setOf<User>()
// This allows multiple screen to be listening to tags, even the same tag
class UserFinderQueryState(
val user: User,
)
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
class UserFinderFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<UserFinderQueryState>(client) {
fun createUserMetadataFilter(keys: Set<UserFinderQueryState>): List<TypedFilter>? {
if (keys.isEmpty()) return null
val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex }
val firstTimers = keys.filter { it.user.latestMetadata == null }.map { it.user.pubkeyHex }.distinct()
if (firstTimers.isEmpty()) return null
@@ -54,10 +63,10 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
)
}
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
fun createUserMetadataStatusReportFilter(keys: Set<UserFinderQueryState>): List<TypedFilter>? {
if (keys.isEmpty()) return null
val secondTimers = usersToWatch.filter { it.latestMetadata != null }
val secondTimers = keys.filter { it.user.latestMetadata != null }.map { it.user }.toSet()
if (secondTimers.isEmpty()) return null
@@ -72,7 +81,14 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, RelationshipStatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
kinds =
listOf(
MetadataEvent.KIND,
StatusEvent.KIND,
RelationshipStatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
),
authors = groupIds,
since = minEOSEs,
),
@@ -94,41 +110,23 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
}
val userChannel =
requestNewChannel { time, relayUrl ->
checkNotInMainThread()
usersToWatch.forEach {
val eose = it.latestEOSEs[relayUrl]
requestNewSubscription { time, relayUrl ->
forEachSubscriber {
val eose = it.user.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
it.user.latestEOSEs = it.user.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
override fun updateChannelFilters() {
checkNotInMainThread()
override fun updateSubscriptions(keys: Set<UserFinderQueryState>) {
userChannel.typedFilters =
listOfNotNull(
createUserMetadataFilter(),
createUserMetadataStatusReportFilter(),
createUserMetadataFilter(keys),
createUserMetadataStatusReportFilter(keys),
).flatten()
.ifEmpty { null }
}
fun add(user: User) {
if (!usersToWatch.contains(user)) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
}
fun remove(user: User) {
if (usersToWatch.contains(user)) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
@Composable
fun UserFinderFilterAssemblerSubscription(user: User) = UserFinderFilterAssemblerSubscription(user, Amethyst.instance.sources.userFinder)
@Composable
fun UserFinderFilterAssemblerSubscription(
user: User,
dataSource: UserFinderFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(user) {
UserFinderQueryState(user)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@Composable
fun observeUser(user: User): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user.live().metadata.observeAsState()
}
@Composable
fun observeUserName(user: User): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.toBestDisplayName() }
.distinctUntilChanged()
.observeAsState(user.toBestDisplayName())
}
@Composable
fun observeUserNip05(user: User): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.nip05 }
.distinctUntilChanged()
.observeAsState(user.info?.nip05)
}
@Composable
fun observeUserAboutMe(user: User): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.about ?: "" }
.distinctUntilChanged()
.observeAsState(user.info?.about ?: "")
}
@Composable
fun observeUserInfo(user: User): State<UserMetadata?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info }
.distinctUntilChanged()
.observeAsState(user.info)
}
@Composable
fun observeUserBanner(user: User): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.banner ?: "" }
.distinctUntilChanged()
.observeAsState(user.info?.banner ?: "")
}
@Composable
fun observeUserPicture(user: User): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.picture }
.distinctUntilChanged()
.observeAsState(user.info?.picture)
}
@Composable
fun observeUserShortName(user: User): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.toBestShortFirstName() }
.distinctUntilChanged()
.observeAsState(user.toBestShortFirstName())
}
@@ -0,0 +1,100 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand
import android.util.Log
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
interface MutableQueryState {
fun flow(): Flow<*>
}
/**
* Creates a data source where the filters are derived from
* data (keys) that is being watched in the UI.
*/
abstract class MutableQueryBasedSubscriptionOrchestrator<T : MutableQueryState>(
client: NostrClient,
val scope: CoroutineScope,
) : SubscriptionOrchestrator(client) {
private var queries: ConcurrentHashMap<T, Job?> = ConcurrentHashMap()
// This is called by main. Keep it really fast.
fun subscribe(query: T?) {
if (query == null) return
val wasEmpty = queries.isEmpty()
queries[query]?.cancel()
queries[query] =
scope.launch {
query.flow().collectLatest {
invalidateFilters()
}
}
if (wasEmpty) {
start()
}
invalidateFilters()
if (isDebug) {
Log.d(this::class.simpleName, "Watch $query (${queries.size} queries)")
}
}
// This is called by main. Keep it really fast.
fun unsubscribe(query: T?) {
if (query == null) return
queries[query]?.cancel()
queries.remove(query)
invalidateFilters()
if (queries.isEmpty()) {
stop()
}
if (isDebug) {
Log.d(this::class.simpleName, "Unwatch $query (${queries.size} queries)")
}
}
fun forEachSubscriber(action: (T) -> Unit) {
queries.keys.forEach(action)
}
final override fun updateSubscriptions() {
updateSubscriptions(queries.keys)
}
abstract fun updateSubscriptions(keys: Set<T>)
}
@@ -0,0 +1,250 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.entities.Note
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.collections.flatten
@Stable
class SearchQueryState(
val searchQuery: MutableStateFlow<String>,
) : MutableQueryState {
override fun flow(): Flow<String> = searchQuery
}
class SearchFilterAssembler(
val cache: LocalCache,
client: NostrClient,
scope: CoroutineScope,
) : MutableQueryBasedSubscriptionOrchestrator<SearchQueryState>(client, scope) {
fun filterByAuthor(pubKey: HexKey) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(pubKey),
limit = 1,
),
),
)
fun filterByEvent(eventId: HexKey) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = listOfNotNull(eventId),
),
),
)
fun filterByAddress(parsed: NAddress) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(parsed.author),
limit = 1,
),
),
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(parsed.kind),
authors = listOfNotNull(parsed.author),
tags = mapOf("d" to listOf(parsed.dTag)),
limit = 5,
),
),
)
private fun createAnythingWithIDFilter(keys: Set<SearchQueryState>): List<TypedFilter> {
if (keys.isEmpty()) return emptyList()
val uniqueQueries =
keys.mapNotNullTo(mutableSetOf()) {
if (!it.searchQuery.value.isBlank()) {
it.searchQuery.value
} else {
null
}
}
return uniqueQueries
.map { mySearchString ->
val directFilters =
runCatching {
if (Hex.isHex(mySearchString)) {
val key = Hex.decode(mySearchString).toHexKey()
filterByAuthor(key) + filterByEvent(key)
} else {
when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) {
is NSec -> filterByAuthor(Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey())
is NPub -> filterByAuthor(parsed.hex)
is NProfile -> filterByAuthor(parsed.hex)
is Note -> filterByEvent(parsed.hex)
is NEvent -> filterByEvent(parsed.hex)
is NEmbed -> {
cache.verifyAndConsume(parsed.event, null)
emptyList()
}
is NRelay -> emptyList()
is NAddress -> filterByAddress(parsed)
else -> emptyList()
}
}
}.getOrDefault(emptyList())
val searchFilters =
listOfNotNull(
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
)
directFilters + searchFilters
}.flatten()
}
val searchChannel = requestNewSubscription()
override fun updateSubscriptions(keys: Set<SearchQueryState>) {
searchChannel.typedFilters =
logTime(
debugMessage = { "Search DataSource UpdateSubscriptions with ${it?.size} filter size" },
block = { createAnythingWithIDFilter(keys).ifEmpty { null } },
)
}
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
@Composable
fun TextSearchDataSourceSubscription(searchBarViewModel: SearchBarViewModel) = TextSearchDataSourceSubscription(searchBarViewModel, Amethyst.instance.sources.search)
@Composable
fun TextSearchDataSourceSubscription(
searchBarViewModel: SearchBarViewModel,
dataSource: SearchFilterAssembler,
) {
KeyDataSourceSubscription(searchBarViewModel.searchDataSourceState, dataSource)
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@Composable
fun UserSearchDataSourceSubscription(userSuggestions: UserSuggestionState) = UserSearchDataSourceSubscription(userSuggestions, Amethyst.instance.sources.search)
@Composable
fun UserSearchDataSourceSubscription(
userSuggestions: UserSuggestionState,
dataSource: SearchFilterAssembler,
) {
KeyDataSourceSubscription(userSuggestions.searchDataSourceState, dataSource)
}
@@ -102,7 +102,10 @@ class MainActivity : AppCompatActivity() {
GlobalScope.launch(Dispatchers.IO) { LanguageTranslatorService.clear() }
GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) }
GlobalScope.launch(Dispatchers.IO) {
debugState(this@MainActivity)
Amethyst.instance.sources.printCounters()
}
super.onPause()
}
@@ -54,7 +54,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -84,7 +83,6 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -135,15 +133,6 @@ fun EditPostView(
postViewModel.load(edit, versionLookingAt, accountViewModel)
}
DisposableEffect(Unit) {
NostrSearchEventOrUserDataSource.start()
onDispose {
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
}
}
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
@@ -251,8 +250,6 @@ open class EditPostViewModel : ViewModel() {
userSuggestions?.reset()
userSuggestionsMainMessage = null
NostrSearchEventOrUserDataSource.clear()
}
open fun findUrlInMessage(): String? =
@@ -1,455 +0,0 @@
/**
* Copyright (c) 2024 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.actions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val searchBarViewModel: SearchBarViewModel =
viewModel(
key = "SearchBarViewModel",
factory =
SearchBarViewModel.Factory(
accountViewModel.account,
),
)
JoinUserOrChannelView(
searchBarViewModel = searchBarViewModel,
onClose = onClose,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun JoinUserOrChannelView(
searchBarViewModel: SearchBarViewModel,
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
Dialog(
onDismissRequest = {
NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clear()
onClose()
},
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
Column(
modifier =
Modifier
.padding(10.dp)
.heightIn(min = 500.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(
onPress = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()
},
)
Text(
text = stringRes(R.string.channel_list_join_conversation),
fontWeight = FontWeight.Bold,
)
Text(
text = "",
color = MaterialTheme.colorScheme.placeholderText,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(15.dp))
RenderSearch(searchBarViewModel, accountViewModel, nav)
}
}
}
}
@Composable
private fun RenderSearch(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val listState = rememberLazyListState()
val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries.
val searchTextChanges = remember { Channel<String>(Channel.CONFLATED) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
checkNotInMainThread()
if (searchBarViewModel.isSearchingFun()) {
searchBarViewModel.invalidateData()
}
}
}
}
LaunchedEffect(Unit) {
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges
.receiveAsFlow()
.filter { it.isNotBlank() }
.distinctUntilChanged()
.debounce(300)
.collectLatest {
if (it.length >= 2) {
NostrSearchEventOrUserDataSource.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
}
}
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Join Start")
NostrSearchEventOrUserDataSource.start()
searchBarViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Join Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
// LAST ROW
SearchEditTextForJoin(searchBarViewModel, searchTextChanges)
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SearchEditTextForJoin(
searchBarViewModel: SearchBarViewModel,
searchTextChanges: Channel<String>,
) {
val scope = rememberCoroutineScope()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
launch {
delay(100)
focusRequester.requestFocus()
}
}
Row(
modifier =
Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.channel_list_user_or_group_id)) },
value = searchBarViewModel.searchValue,
onValueChange = {
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) }
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
modifier =
Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringRes(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
},
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringRes(R.string.clear),
)
}
}
},
)
}
}
@Composable
private fun RenderSearchResults(
searchBarViewModel: SearchBarViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle()
Row(
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(vertical = 10.dp),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(
users,
key = { _, item -> "u" + item.pubkeyHex },
) { _, item ->
UserComposeForChat(item, accountViewModel) {
accountViewModel.createChatRoomFor(item) { nav.nav(Route.Room(it)) }
searchBarViewModel.clear()
}
HorizontalDivider(
thickness = DividerThickness,
)
}
itemsIndexed(
channels,
key = { _, item -> "c" + item.idHex },
) { _, item ->
RenderChannel(
item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
) {
nav.nav(Route.Channel(item.idHex))
searchBarViewModel.clear()
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
}
@Composable
private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
onClick: () -> Unit,
) {
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
item.toBestDisplayName(),
fontWeight = FontWeight.Bold,
)
},
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages = false,
onClick = onClick,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
)
}
@Composable
fun UserComposeForChat(
baseUser: User,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.clickable(
onClick = onClick,
).padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) }
DisplayUserAboutInfo(baseUser)
}
}
}
@Composable
private fun DisplayUserAboutInfo(baseUser: User) {
val baseUserState by baseUser.live().metadata.observeAsState()
val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } }
Text(
text = about,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.R.attr.category
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Stable
@@ -45,7 +44,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -1106,8 +1104,6 @@ open class NewPostViewModel :
emojiSuggestions?.reset()
draftTag = UUID.randomUUID().toString()
NostrSearchEventOrUserDataSource.clear()
}
fun deleteDraft() {
@@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -58,6 +57,9 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
@@ -190,8 +192,7 @@ private fun DisplayNoteLink(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by it.live().metadata.observeAsState()
val noteState by observeNote(it)
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
@@ -214,7 +215,7 @@ private fun DisplayNoteLink(
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelState by observeChannel(baseChannel)
val channelDisplayName by
remember(channelState) {
derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote }
@@ -254,7 +255,7 @@ private fun DisplayAddress(
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val noteState by observeNote(it)
val route = remember(noteState) { Route.Note(nip19.aTag()) }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
@@ -324,7 +325,7 @@ public fun RenderUserAsClickableText(
additionalChars: String?,
nav: INav,
) {
val userState by baseUser.live().userMetadataInfo.observeAsState()
val userState by observeUserInfo(baseUser)
CreateClickableTextWithEmoji(
clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()),
@@ -40,7 +40,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -86,6 +85,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
@@ -777,7 +777,7 @@ private fun DisplayUserFromTag(
accountViewModel: AccountViewModel,
nav: INav,
) {
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
val meta by observeUserInfo(baseUser)
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) {
Row {
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -39,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.DisplayFullNote
import com.vitorpamplona.amethyst.ui.components.DisplayUser
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
@@ -231,7 +231,7 @@ class MarkdownMediaRenderer(
) {
renderInvisible(richTextStringBuilder) {
// Preloads note if not loaded yet.
baseNote.live().metadata.observeAsState()
EventFinderFilterAssemblerSubscription(baseNote)
}
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2024 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.dal
import kotlin.time.measureTimedValue
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
open fun updateListWith(
oldList: List<T>,
newItems: Set<T>,
): List<T> {
val (feed, elapsed) =
measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
val newList = oldList.toSet() + newItemsToBeAdded
sort(newList).take(limit())
} else {
oldList
}
}
// Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size}
// objects")
return feed
}
}
@@ -21,13 +21,10 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.measureTimedValue
abstract class FeedFilter<T> {
fun loadTop(): List<T> {
checkNotInMainThread()
val (feed, elapsed) = measureTimedValue { feed() }
Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects")
@@ -43,31 +40,3 @@ abstract class FeedFilter<T> {
abstract fun feed(): List<T>
}
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
open fun updateListWith(
oldList: List<T>,
newItems: Set<T>,
): List<T> {
checkNotInMainThread()
val (feed, elapsed) =
measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
val newList = oldList.toSet() + newItemsToBeAdded
sort(newList).take(limit())
} else {
oldList
}
}
// Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size}
// objects")
return feed
}
}
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
interface InvalidatableContent {
fun invalidateData(ignoreIfDoing: Boolean = false)
val isRefreshing: MutableState<Boolean>
val isRefreshing: State<Boolean>
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2024 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.feeds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.LifecycleResumeEffect
@Composable
fun WatchLifecycleAndUpdateModel(model: InvalidatableContent) {
LaunchedEffect(model) {
model.invalidateData(true)
}
LifecycleResumeEffect(model) {
model.invalidateData(true)
onPauseOrDispose {}
}
}
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import kotlin.math.abs
@@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -61,6 +60,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -214,7 +215,7 @@ private fun AccountPicture(
loadProfilePicture: Boolean,
loadRobohash: Boolean,
) {
val profilePicture by user.live().profilePictureChanges.observeAsState()
val profilePicture by observeUserPicture(user)
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
@@ -231,7 +232,7 @@ private fun AccountName(
acc: AccountInfo,
user: User,
) {
val info by user.live().userMetadataInfo.observeAsState()
val info by observeUserInfo(user)
info?.let {
it.bestName()?.let { name ->
@@ -46,15 +46,14 @@ import androidx.core.util.Consumer
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
@@ -72,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
@@ -29,13 +29,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -89,12 +89,7 @@ private fun LoggedInUserPictureDrawer(
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
val profilePicture by
accountViewModel.account
.userProfile()
.live()
.profilePictureChanges
.observeAsState()
val profilePicture by observeUserPicture(accountViewModel.userProfile())
RobohashFallbackAsyncImage(
robot = accountViewModel.userProfile().pubkeyHex,
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
import android.R.attr.maxLines
import android.R.attr.onClick
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -92,12 +90,14 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -183,7 +183,7 @@ fun ProfileContent(
accountViewModel: AccountViewModel,
onClickUser: () -> Unit,
) {
val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState()
val userInfo by observeUserInfo(baseAccountUser)
ProfileContentTemplate(
profilePubHex = baseAccountUser.pubkeyHex,
@@ -270,9 +270,9 @@ private fun EditStatusBoxes(
StatusEditBar(accountViewModel = accountViewModel, nav = nav)
} else {
statuses.forEach {
val originalStatus by it.live().content.observeAsState()
val noteStatus by observeNote(it)
StatusEditBar(originalStatus, it.address, accountViewModel, nav)
StatusEditBar(noteStatus?.note?.event?.content, it.address, accountViewModel, nav)
}
}
}
@@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -48,7 +47,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -56,6 +54,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
@@ -251,11 +250,7 @@ fun RenderOption(option: Name) {
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val noteState by
option.note
.live()
.metadata
.observeAsState()
val noteState by observeNote(option.note)
val name = (noteState?.note?.event as? PeopleListEvent)?.nameOrTitle() ?: option.note.dTag() ?: ""
@@ -267,14 +262,9 @@ fun RenderOption(option: Name) {
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val name by
option.note
.live()
.metadata
.map { "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") }
.observeAsState()
val it by observeNote(option.note)
Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface)
Text(text = "/n/${((it?.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface)
}
}
}
@@ -21,10 +21,8 @@
package com.vitorpamplona.amethyst.ui.navigation
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache.users
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -36,13 +36,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
@@ -61,10 +61,7 @@ fun BadgeCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by likeSetCard.note
.live()
.metadata
.observeAsState()
val noteState by observeNote(likeSetCard.note)
val note = noteState?.note
val context = LocalContext.current.applicationContext
@@ -43,7 +43,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -62,8 +61,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.vitorpamplona.amethyst.R
@@ -72,6 +69,10 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
@@ -302,28 +303,16 @@ fun RenderClassifiedsThumb(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? ClassifiedsEvent ?: return
if (baseNote.event !is ClassifiedsEvent) return
val card by
baseNote
.live()
.metadata
.map {
val noteEvent = it.note.event as? ClassifiedsEvent
ClassifiedsThumb(
image = noteEvent?.image(),
title = noteEvent?.title(),
price = noteEvent?.price(),
)
}.distinctUntilChanged()
.observeAsState(
ClassifiedsThumb(
image = noteEvent.image(),
title = noteEvent.title(),
price = noteEvent.price(),
),
)
val card by observeNoteAndMap(baseNote) {
val noteEvent = it.event as? ClassifiedsEvent
ClassifiedsThumb(
image = noteEvent?.image(),
title = noteEvent?.title(),
price = noteEvent?.price(),
)
}
InnerRenderClassifiedsThumb(card, baseNote)
}
@@ -425,39 +414,36 @@ fun RenderLiveActivityThumb(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
val card by observeNoteAndMap(baseNote) {
val noteEvent = it.event as? LiveActivitiesEvent
val card by
baseNote
.live()
.metadata
.map {
val noteEvent = it.note.event as? LiveActivitiesEvent
LiveActivityCard(
name = noteEvent?.dTag() ?: "",
cover = noteEvent?.image()?.ifBlank { null },
media = noteEvent?.streaming(),
subject = noteEvent?.title()?.ifBlank { null },
content = noteEvent?.summary(),
participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(),
status = noteEvent?.status(),
starts = noteEvent?.starts(),
)
}
LiveActivityCard(
name = noteEvent?.dTag() ?: "",
cover = noteEvent?.image()?.ifBlank { null },
media = noteEvent?.streaming(),
subject = noteEvent?.title()?.ifBlank { null },
content = noteEvent?.summary(),
participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(),
status = noteEvent?.status(),
starts = noteEvent?.starts(),
)
}.distinctUntilChanged()
.observeAsState(
LiveActivityCard(
name = noteEvent.dTag(),
cover = noteEvent.image()?.ifBlank { null },
media = noteEvent.streaming(),
subject = noteEvent.title()?.ifBlank { null },
content = noteEvent.summary(),
participants = noteEvent.participants().toImmutableList(),
status = noteEvent.status(),
starts = noteEvent.starts(),
),
)
RenderLiveActivityThumb(
card,
baseNote,
accountViewModel,
nav,
)
}
@Composable
fun RenderLiveActivityThumb(
card: LiveActivityCard,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
@@ -560,31 +546,29 @@ fun RenderCommunitiesThumb(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return
val noteState by observeNote(baseNote)
val noteEvent = noteState?.note?.event as? CommunityDefinitionEvent ?: return
val card by
baseNote
.live()
.metadata
.map {
val noteEvent = it.note.event as? CommunityDefinitionEvent
CommunityCard(
name = noteEvent?.dTag() ?: "",
description = noteEvent?.description(),
cover = noteEvent?.image()?.imageUrl,
moderators = noteEvent?.moderatorKeys()?.toImmutableList() ?: persistentListOf(),
)
}.distinctUntilChanged()
.observeAsState(
CommunityCard(
name = noteEvent.dTag(),
description = noteEvent.description(),
cover = noteEvent.image()?.imageUrl,
moderators = noteEvent.moderatorKeys().toImmutableList(),
),
)
RenderCommunitiesThumb(
CommunityCard(
name = noteEvent.dTag(),
description = noteEvent.description(),
cover = noteEvent.image()?.imageUrl,
moderators = noteEvent.moderatorKeys().toImmutableList(),
),
baseNote,
accountViewModel,
nav,
)
}
@Composable
fun RenderCommunitiesThumb(
card: CommunityCard,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
LeftPictureLayout(
onImage = {
card.cover?.let {
@@ -767,12 +751,7 @@ fun RenderContentDVMThumb(
nav: INav,
) {
// downloads user metadata to pre-load the NIP-65 relays.
val user =
baseNote.author
?.live()
?.metadata
?.observeAsState()
baseNote.author?.let { UserFinderFilterAssemblerSubscription(it) }
val card = observeAppDefinition(appDefinitionNote = baseNote)
LeftPictureLayout(
@@ -791,7 +770,7 @@ fun RenderContentDVMThumb(
)
}
} ?: run {
user?.value?.user?.let {
baseNote.author?.let {
BannerImage(
it,
Modifier
@@ -930,7 +909,7 @@ fun RenderChannelThumb(
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelUpdates by channel.live.observeAsState()
val channelUpdates by observeChannel(channel)
val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" }
val description = remember(channelUpdates) { channelUpdates?.channel?.summary()?.ifBlank { null } }
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -162,7 +163,7 @@ fun LoadOts(
) {
var earliestDate: GenericLoadable<Long> by remember { mutableStateOf(GenericLoadable.Loading()) }
val noteStatus by note.live().innerOts.observeAsState()
val noteStatus by observeNoteOts(note)
LaunchedEffect(key1 = noteStatus) {
accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts ->
@@ -43,7 +43,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
@@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
@@ -623,7 +623,7 @@ private fun WatchUserMetadata(
author: User,
onNewMetadata: @Composable (String?) -> Unit,
) {
val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture())
val userProfile by observeUserPicture(author)
onNewMetadata(userProfile)
}
@@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,6 +51,9 @@ import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNip05
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
@@ -133,7 +135,7 @@ fun ObserveDisplayNip05Status(
accountViewModel: AccountViewModel,
nav: INav,
) {
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
val nip05 by observeUserNip05(baseUser)
LoadStatuses(baseUser, accountViewModel) { statuses ->
CrossfadeIfEnabled(
@@ -204,10 +206,9 @@ fun ObserveRotateStatuses(
@Composable
fun ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses: ImmutableList<AddressableNote>) {
statuses
.map {
it.live().metadata.observeAsState()
}
statuses.map {
EventFinderFilterAssemblerSubscription(it)
}
}
@Composable
@@ -247,7 +248,7 @@ fun DisplayStatus(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by addressableNote.live().metadata.observeAsState()
val noteState by observeNote(addressableNote)
val noteEvent = noteState?.note?.event as? StatusEvent ?: return
DisplayStatus(noteEvent, accountViewModel, nav)
@@ -52,12 +52,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -849,14 +853,14 @@ fun ObserveDraftEvent(
accountViewModel: AccountViewModel,
render: @Composable (Note) -> Unit,
) {
val noteState by note.live().metadata.observeAsState()
val noteEvent by observeNoteEvent<DraftEvent>(note)
val noteEvent = noteState?.note?.event as? DraftEvent ?: return
noteEvent?.let {
val innerNote by produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = it)
val innerNote = produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = noteEvent)
innerNote.value?.let {
render(it)
innerNote?.let {
render(it)
}
}
}
@@ -1109,7 +1113,7 @@ fun observeEdits(
)
}
val updatedNote by baseNote.live().innerModifications.observeAsState()
val updatedNote by observeNoteEdits(baseNote)
LaunchedEffect(key1 = updatedNote) {
updatedNote?.note?.let {
@@ -1186,6 +1190,8 @@ private fun ChannelNotePicture(
loadProfilePicture: Boolean,
loadRobohash: Boolean,
) {
ChannelFinderFilterAssemblerSubscription(baseChannel, Amethyst.instance.sources.channelFinder)
val model by
baseChannel.live
.map { it.channel.profilePicture() }
@@ -90,14 +90,13 @@ import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.nip19Bech32.toNAddr
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import kotlinx.coroutines.launch
@@ -51,7 +51,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -75,6 +74,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.toasts.StringToastMsg
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
@@ -283,7 +283,7 @@ private fun WatchZapsAndUpdateTallies(
baseNote: Note,
pollViewModel: PollNoteViewModel,
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapsState by observeNoteZaps(baseNote)
LaunchedEffect(key1 = zapsState) { pollViewModel.refreshTallies() }
}
@@ -65,7 +65,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -97,15 +96,20 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReferences
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReposts
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostsBy
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -344,7 +348,7 @@ fun RenderZapRaiser(
details: Boolean,
accountViewModel: AccountViewModel,
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapsState by observeNoteZaps(baseNote)
var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
@@ -394,15 +398,7 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(
baseNote: Note,
content: @Composable () -> Unit,
) {
val hasReactions by
baseNote
.live()
.hasReactions
.observeAsState(
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty(),
)
val hasReactions by observeNoteReferences(baseNote)
if (hasReactions) {
content()
@@ -463,15 +459,7 @@ private fun ReactionDetailGallery(
val defaultBackgroundColor = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val hasReactions by
baseNote
.live()
.hasReactions
.observeAsState(
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty(),
)
val hasReactions by observeNoteReferences(baseNote)
if (hasReactions) {
Row(
@@ -480,8 +468,8 @@ private fun ReactionDetailGallery(
) {
Column {
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
// WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
// WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
}
}
}
@@ -493,7 +481,7 @@ private fun WatchBoostsAndRenderGallery(
nav: INav,
accountViewModel: AccountViewModel,
) {
val boostsEvents by baseNote.live().boosts.observeAsState()
val boostsEvents by observeNoteReposts(baseNote)
boostsEvents?.let {
if (it.note.boosts.isNotEmpty()) {
@@ -512,7 +500,7 @@ private fun WatchReactionsAndRenderGallery(
nav: INav,
accountViewModel: AccountViewModel,
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactionsState by observeNoteReactions(baseNote)
val reactionEvents = reactionsState?.note?.reactions ?: return
if (reactionEvents.isNotEmpty()) {
@@ -535,7 +523,7 @@ private fun WatchZapAndRenderGallery(
nav: INav,
accountViewModel: AccountViewModel,
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapsState by observeNoteZaps(baseNote)
var zapEvents by
remember(zapsState) {
@@ -692,7 +680,7 @@ fun ReplyCounter(
textColor: Color,
accountViewModel: AccountViewModel,
) {
val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size)
val repliesState by observeNoteReplyCount(baseNote)
SlidingAnimationCount(repliesState, textColor, accountViewModel)
}
@@ -827,16 +815,7 @@ fun ObserveBoostIcon(
accountViewModel: AccountViewModel,
inner: @Composable (Boolean) -> Unit,
) {
val hasBoosted by
remember(baseNote) {
baseNote
.live()
.boosts
.map { it.note.isBoostedBy(accountViewModel.userProfile()) }
.distinctUntilChanged()
}.observeAsState(
baseNote.isBoostedBy(accountViewModel.userProfile()),
)
val hasBoosted by observeNoteRepostsBy(baseNote, accountViewModel.userProfile())
inner(hasBoosted)
}
@@ -847,7 +826,7 @@ fun BoostText(
grayTint: Color,
accountViewModel: AccountViewModel,
) {
val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size)
val boostState by observeNoteRepostCount(baseNote)
SlidingAnimationCount(boostState, grayTint, accountViewModel)
}
@@ -917,7 +896,7 @@ fun ObserveLikeIcon(
accountViewModel: AccountViewModel,
inner: @Composable (String?) -> Unit,
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactionsState by observeNoteReactions(baseNote)
@Suppress("ProduceStateDoesNotAssignValue")
val reactionType by
@@ -974,7 +953,7 @@ fun ObserveLikeText(
baseNote: Note,
inner: @Composable (Int) -> Unit,
) {
val reactionCount by baseNote.live().reactionCount.observeAsState(0)
val reactionCount by observeNoteReactionCount(baseNote)
inner(reactionCount)
}
@@ -1227,7 +1206,7 @@ fun ObserveZapIcon(
val wasZappedByLoggedInUser = remember { mutableStateOf(false) }
if (!wasZappedByLoggedInUser.value) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapsState by observeNoteZaps(baseNote)
LaunchedEffect(key1 = zapsState) {
if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) {
@@ -1249,7 +1228,7 @@ fun ObserveZapAmountText(
accountViewModel: AccountViewModel,
inner: @Composable (String) -> Unit,
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapsState by observeNoteZaps(baseNote)
if (zapsState?.note?.zapPayments?.isNotEmpty() == true) {
@Suppress("ProduceStateDoesNotAssignValue")
@@ -29,7 +29,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -37,6 +36,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
@@ -120,7 +120,7 @@ private fun ReplyInfoMention(
prefix: String,
onUserTagClick: (User) -> Unit,
) {
val innerUserState by user.live().userMetadataInfo.observeAsState()
val innerUserState by observeUserInfo(user)
CreateClickableTextWithEmoji(
clickablePart = "$prefix${innerUserState?.bestName()}",
@@ -55,7 +55,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -72,8 +71,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -81,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
@@ -387,17 +385,9 @@ private fun EmojiSelector(
accountViewModel,
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by
usersEmojiList
.live()
.metadata
.map { (it.note.event as? EmojiPackSelectionEvent)?.emojiPackIds()?.toImmutableList() }
.distinctUntilChanged()
.observeAsState(
(usersEmojiList.event as? EmojiPackSelectionEvent)
?.emojiPackIds()
?.toImmutableList(),
)
val collections by observeNoteEventAndMap(usersEmojiList) { event: EmojiPackSelectionEvent ->
event.emojiPackIds().toImmutableList()
}
collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) }
}
@@ -92,7 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -40,6 +39,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -328,7 +328,7 @@ fun LoadUserProfilePicture(
baseUser: User,
innerContent: @Composable (String?, String?) -> Unit,
) {
val userProfile by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
val userProfile by observeUserInfo(baseUser)
innerContent(userProfile?.profilePicture(), userProfile?.bestName())
}
@@ -27,7 +27,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -38,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -67,8 +68,7 @@ fun WatchAuthor(
if (noteAuthor != null) {
inner(noteAuthor)
} else {
val authorState by baseNote.live().metadata.observeAsState()
val authorState by observeNote(baseNote)
authorState?.note?.author?.let {
inner(it)
}
@@ -86,8 +86,7 @@ fun WatchAuthorWithBlank(
if (noteAuthor != null) {
inner(noteAuthor)
} else {
val authorState by baseNote.live().metadata.observeAsState()
val authorState by observeNote(baseNote)
CrossfadeIfEnabled(targetState = authorState?.note?.author, modifier = modifier, label = "WatchAuthorWithBlank", accountViewModel = accountViewModel) { newAuthor ->
inner(newAuthor)
}
@@ -102,7 +101,7 @@ fun UsernameDisplay(
textColor: Color = Color.Unspecified,
accountViewModel: AccountViewModel,
) {
val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
val userMetadata by observeUserInfo(baseUser)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
val name = it?.bestName()
@@ -24,10 +24,10 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -74,8 +74,7 @@ fun WatchNoteEvent(
onNoteEventFound()
} else {
// avoid observing costs if already has an event.
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
val hasEvent by observeNoteHasEvent(baseNote)
CrossfadeIfEnabled(targetState = hasEvent, label = "Event presence", accountViewModel = accountViewModel) {
if (it) {
onNoteEventFound()
@@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -44,6 +43,8 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -65,10 +66,7 @@ fun ZapNoteCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseNoteRequest by baseReqResponse.zapRequest
.live()
.metadata
.observeAsState()
val baseNoteRequest by observeNote(baseReqResponse.zapRequest)
var baseAuthor by remember { mutableStateOf<User?>(null) }
@@ -135,7 +133,7 @@ private fun RenderZapNote(
@Composable
private fun ZapAmount(zapEventNote: Note) {
val noteState by zapEventNote.live().metadata.observeAsState()
val noteState by observeNote(zapEventNote)
var zapAmount by remember { mutableStateOf<String?>(null) }
@@ -221,12 +219,10 @@ fun ShowFollowingOrUnfollowingButton(
@Composable
fun AboutDisplay(baseAuthor: User) {
val baseAuthorState by baseAuthor.live().metadata.observeAsState()
val userAboutMe by
remember(baseAuthorState) { derivedStateOf { baseAuthorState?.user?.info?.about ?: "" } }
val aboutMe by observeUserAboutMe(baseAuthor)
Text(
userAboutMe,
aboutMe,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -22,9 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
@@ -38,23 +37,15 @@ fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) {
accountViewModel,
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by usersEmojiList
.live()
.metadata
.map {
(it.note.event as? EmojiPackSelectionEvent)
?.taggedAddresses()
?.toImmutableList()
}.distinctUntilChanged()
.observeAsState(
(usersEmojiList.event as? EmojiPackSelectionEvent)
?.taggedAddresses()
?.toImmutableList(),
)
val collections by observeNoteEventAndMap(usersEmojiList) { event: EmojiPackSelectionEvent ->
event.taggedAddresses().toImmutableList()
}
collections?.forEach {
LoadAddressableNote(it, accountViewModel) {
it?.live()?.metadata?.observeAsState()
collections?.forEach { address ->
LoadAddressableNote(address, accountViewModel) { note ->
if (note != null) {
EventFinderFilterAssemblerSubscription(note)
}
}
}
}
@@ -28,15 +28,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -50,14 +55,50 @@ fun ShowUserSuggestionList(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
) {
val userSuggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
UserSearchDataSourceSubscription(userSuggestions)
if (userSuggestions.isNotEmpty()) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
LaunchedEffect(Unit) {
LocalCache.live.newEventBundles.collect {
userSuggestions.invalidateData()
}
}
WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier)
}
@Composable
fun AnimateOnNewSearch(
userSuggestions: UserSuggestionState,
listState: LazyListState,
) {
val searchTerm by userSuggestions.searchTerm.collectAsStateWithLifecycle("")
LaunchedEffect(searchTerm) {
listState.animateScrollToItem(0)
}
}
@Composable
fun WatchResponses(
userSuggestions: UserSuggestionState,
listState: LazyListState,
onSelect: (User) -> Unit,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
) {
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier = modifier,
state = listState,
) {
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
UserLine(item, accountViewModel) { onSelect(item) }
HorizontalDivider(
thickness = DividerThickness,
@@ -22,43 +22,73 @@ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
class UserSuggestionState(
val accountViewModel: AccountViewModel,
) {
var search = MutableStateFlow("")
var results =
search
.debounce(500)
val invalidations = MutableStateFlow<Int>(0)
val currentWord = MutableStateFlow("")
val searchDataSourceState = SearchQueryState(MutableStateFlow(""))
@OptIn(FlowPreview::class)
val searchTerm =
currentWord
.debounce(300)
.distinctUntilChanged()
.map { word ->
if (word.startsWith("@") && word.length > 2) {
val prefix = word.removePrefix("@")
NostrSearchEventOrUserDataSource.search(prefix)
.map(::userSearchTermOrNull)
.onEach(::updateDataSource)
val results =
combine(searchTerm, invalidations.debounce(100)) { prefix, version ->
if (prefix != null) {
logTime("UserSuggestionState Search $prefix version $version") {
accountViewModel.findUsersStartingWithSync(prefix)
} else {
NostrSearchEventOrUserDataSource.clear()
search.tryEmit("")
emptyList()
}
}.flowOn(Dispatchers.IO)
} else {
emptyList()
}
}.flowOn(Dispatchers.Default)
fun reset() {
NostrSearchEventOrUserDataSource.clear()
search.tryEmit("")
currentWord.tryEmit("")
}
fun processCurrentWord(word: String) {
search.tryEmit(word)
currentWord.tryEmit(word)
}
fun invalidateData() {
// force new query
invalidations.update { it + 1 }
}
fun userSearchTermOrNull(currentWord: String): String? =
if (currentWord.startsWith("@") && currentWord.length > 2) {
currentWord.removePrefix("@")
} else {
null
}
fun updateDataSource(searchTerm: String?) {
if (searchTerm != null) {
searchDataSourceState.searchQuery.tryEmit(searchTerm)
} else {
searchDataSourceState.searchQuery.tryEmit("")
}
}
fun replaceCurrentWord(
@@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -39,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -106,8 +106,7 @@ fun ObserveRelayListForDMs(
accountViewModel,
) { relayList ->
if (relayList != null) {
val relayListNoteState by relayList.live().metadata.observeAsState()
val relayListEvent = relayListNoteState?.note?.event as? ChatMessageRelayListEvent
val relayListEvent by observeNoteEvent<ChatMessageRelayListEvent>(relayList)
inner(relayListEvent)
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -36,6 +35,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,20 +64,21 @@ fun BannerImage(
author: User,
imageModifier: Modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp),
) {
val currentInfo by author.live().userMetadataInfo.observeAsState()
currentInfo?.banner?.let {
val banner by observeUserBanner(author)
if (banner.isNotBlank()) {
AsyncImage(
model = it,
model = banner,
contentDescription =
stringRes(
R.string.preview_card_image_for,
it,
banner,
),
contentScale = ContentScale.Crop,
modifier = imageModifier,
placeholder = painterResource(R.drawable.profile_banner),
)
} ?: run {
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringRes(R.string.profile_banner),
@@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -58,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies
import com.vitorpamplona.amethyst.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -115,7 +115,7 @@ private fun RenderPledgeAmount(
baseReward: Reward,
accountViewModel: AccountViewModel,
) {
val repliesState by baseNote.live().replies.observeAsState()
val repliesState by observeNoteReplies(baseNote)
var reward by remember {
mutableStateOf<String>(
showAmount(baseReward.amount),
@@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
@@ -27,13 +27,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.appendLink
@@ -80,7 +82,7 @@ fun ForkInformationRowLightColor(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by originalVersion.live().metadata.observeAsState()
val noteState by observeNote(originalVersion)
val note = noteState?.note ?: return
val author = note.author ?: return
val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
@@ -103,20 +105,16 @@ fun ForkInformationRowLightColor(
overflow = TextOverflow.Visible,
)
val userState by author.live().metadata.observeAsState()
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags =
remember(userState) { userState?.user?.info?.tags }
if (userDisplayName != null) {
val userState by observeUser(author)
userState?.user?.toBestDisplayName()?.let {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
clickablePart = it,
maxLines = 1,
route = route,
overrideColor = MaterialTheme.colorScheme.nip05,
fontSize = Font14SP,
nav = nav,
tags = userTags,
tags = userState?.user?.info?.tags,
)
}
}
@@ -130,19 +128,19 @@ fun ForkInformationRow(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by originalVersion.live().metadata.observeAsState()
val noteState by observeNote(originalVersion)
val note = noteState?.note ?: return
val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
if (route != null) {
Row(modifier) {
val author = note.author ?: return
val meta by author.live().userMetadataInfo.observeAsState(author.info)
val meta by observeUserInfo(author)
Text(stringRes(id = R.string.forked_from))
Spacer(modifier = StdHorzSpacer)
val userMetadata by author.live().userMetadataInfo.observeAsState()
val userMetadata by observeUserInfo(author)
CreateClickableTextWithEmoji(
clickablePart = remember(meta) { meta?.bestName() ?: author.pubkeyDisplayHex() },
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import android.R.attr.onClick
import android.content.Context
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
@@ -40,7 +39,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -65,6 +63,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.appendLink
@@ -189,7 +188,7 @@ fun ZapTheDevsCard(
accountViewModel: AccountViewModel,
nav: INav,
) {
val releaseNoteState by baseNote.live().metadata.observeAsState()
val releaseNoteState by observeNote(baseNote)
val releaseNote = releaseNoteState?.note ?: return
Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) {
@@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -47,6 +46,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -58,16 +58,17 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
@Composable
fun BadgeDisplay(baseNote: Note) {
val observingNote by baseNote.live().metadata.observeAsState()
val badgeData = observingNote?.note?.event as? BadgeDefinitionEvent ?: return
val badgeData by observeNoteEvent<BadgeDefinitionEvent>(baseNote)
RenderBadge(
badgeData.image(),
badgeData.name(),
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.onBackground,
badgeData.description(),
)
badgeData?.let {
RenderBadge(
it.image(),
it.name(),
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.onBackground,
it.description(),
)
}
}
@Preview
@@ -35,7 +35,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -52,6 +51,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -119,19 +119,17 @@ fun LongCommunityHeader(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent =
remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote)
Row(
lineModifier,
) {
val rulesLabel = stringRes(id = R.string.rules)
val summary =
remember(noteState) {
val subject = noteEvent.subject()?.ifEmpty { null }
val body = noteEvent.description()?.ifBlank { null }
val rules = noteEvent.rules()?.ifBlank { null }
remember(noteEvent) {
val subject = noteEvent?.subject()?.ifEmpty { null }
val body = noteEvent?.description()?.ifBlank { null }
val rules = noteEvent?.rules()?.ifBlank { null }
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
if (rules == null) {
@@ -167,12 +165,14 @@ fun LongCommunityHeader(
)
}
if (summary != null && noteEvent.hasHashtags()) {
DisplayUncitedHashtags(
event = noteEvent,
content = summary,
nav = nav,
)
noteEvent?.let {
if (it.hasHashtags()) {
DisplayUncitedHashtags(
event = it,
content = summary ?: "",
nav = nav,
)
}
}
}
@@ -207,8 +207,8 @@ fun LongCommunityHeader(
)
}
LaunchedEffect(key1 = noteState) {
val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators()
LaunchedEffect(key1 = noteEvent) {
val participants = noteEvent?.moderators()
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
@@ -263,12 +263,10 @@ fun ShortCommunityHeader(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent =
remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote)
Row(verticalAlignment = Alignment.CenterVertically) {
noteEvent.image()?.let {
noteEvent?.image()?.let {
RobohashFallbackAsyncImage(
robot = baseNote.idHex,
model = it.imageUrl,
@@ -290,7 +288,7 @@ fun ShortCommunityHeader(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(noteState) { noteEvent.dTag() },
text = noteEvent?.name() ?: baseNote.dTag(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -34,7 +34,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -46,10 +45,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -71,17 +70,9 @@ public fun RenderEmojiPack(
accountViewModel: AccountViewModel,
onClick: ((EmojiUrlTag) -> Unit)? = null,
) {
val noteEvent by
baseNote
.live()
.metadata
.map { it.note.event }
.distinctUntilChanged()
.observeAsState(baseNote.event)
val noteEvent by observeNoteEvent<EmojiPackEvent>(baseNote)
if (noteEvent == null || noteEvent !is EmojiPackEvent) return
(noteEvent as? EmojiPackEvent)?.let {
noteEvent?.let {
RenderEmojiPack(
noteEvent = it,
baseNote = baseNote,
@@ -188,14 +179,7 @@ private fun EmojiListOptions(
accountViewModel,
) {
it?.let { usersEmojiList ->
val hasAddedThis by
remember {
usersEmojiList
.live()
.metadata
.map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) }
.distinctUntilChanged()
}.observeAsState()
val hasAddedThis by observeNoteAndMap(usersEmojiList) { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) }
CrossfadeIfEnabled(targetState = hasAddedThis, label = "EmojiListOptions", accountViewModel = accountViewModel) {
if (it != true) {
@@ -22,16 +22,15 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
@@ -68,9 +67,7 @@ private fun ObserverAndRenderNIP95(
) {
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
val appContext = LocalContext.current.applicationContext
val noteState by content.live().metadata.observeAsState()
val noteState by observeNote(content)
val content by
remember(noteState) {
@@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -43,6 +42,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -100,13 +100,12 @@ private fun RenderShortRepositoryHeader(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return
val noteEvent by observeNoteEvent<GitRepositoryEvent>(baseNote)
Column(
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
) {
val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() }
val title = noteEvent?.name() ?: baseNote.dTag()
Text(
text = stringRes(id = R.string.git_repository, title),
style = MaterialTheme.typography.titleLarge,
@@ -115,7 +114,7 @@ private fun RenderShortRepositoryHeader(
modifier = Modifier.fillMaxWidth(),
)
noteEvent.description()?.let {
noteEvent?.description()?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,

Some files were not shown because too many files have changed in this diff Show More