diff --git a/.gitignore b/.gitignore index e5303fd75d..f527be1420 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /.idea/studiobot.xml /.idea/other.xml /.idea/runConfigurations.xml +/.idea/ChatHistory_schema_v2.xml .DS_Store /build /captures diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000000..91f95584dd --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/kotlinNotebook.xml b/.idea/kotlinNotebook.xml new file mode 100644 index 0000000000..665e38dae3 --- /dev/null +++ b/.idea/kotlinNotebook.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index bb4493707f..bdfaa5e068 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,5 +1,11 @@ + + + + diff --git a/README.md b/README.md index 3ed3b45d97..27e3714122 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Lastly, the user's account information (private key/pub key) is stored in the An ## Setup Make sure to have the following pre-requisites installed: -1. Java 17+ +1. Java 21+ 2. Android Studio 3. Android 8.0+ Phone or Emulation setup diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 38147f0ca7..ab20337e63 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -9,20 +9,20 @@ plugins { } android { - namespace 'com.vitorpamplona.amethyst' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.amethyst' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - applicationId "com.vitorpamplona.amethyst" - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() - versionCode 418 - versionName "0.94.3" + applicationId = "com.vitorpamplona.amethyst" + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() + versionCode = 418 + versionName = "0.94.3" buildConfigField "String", "RELEASE_NOTES_ID", "\"fd42b23b9ef792059b1c1a89555443abbb11578f4b3c8430b452559eec7325f3\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { - useSupportLibrary true + useSupportLibrary = true } resourceConfigurations += [ 'ar', @@ -107,7 +107,7 @@ android { buildTypes { release { proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro' - minifyEnabled true + minifyEnabled = true } debug { applicationIdSuffix '.debug' @@ -119,8 +119,8 @@ android { applicationIdSuffix '.benchmark' versionNameSuffix '-BENCHMARK' resValue "string", "app_name", "@string/app_name_benchmark" - profileable true - signingConfig signingConfigs.debug + profileable = true + signingConfig = signingConfigs.debug } } @@ -139,10 +139,10 @@ android { splits { abi { - enable true + enable = true reset() include "x86", "x86_64", "arm64-v8a", "armeabi-v7a" - universalApk true + universalApk = true } } @@ -152,8 +152,8 @@ android { } buildFeatures { - compose true - buildConfig true + compose = true + buildConfig = true } packagingOptions { @@ -171,6 +171,17 @@ android { } } +// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5 +configurations.all { + def tink = "com.google.crypto.tink:tink-android:1.17.0" + resolutionStrategy { + force(tink) + dependencySubstitution { + substitute module('com.google.crypto.tink:tink') using module(tink) + } + } +} + kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 @@ -222,9 +233,11 @@ dependencies { // Websockets API implementation libs.okhttp + implementation libs.okhttpCoroutines // Encrypted Key Storage implementation libs.androidx.security.crypto.ktx + implementation libs.androidx.datastore.preferences // view videos implementation libs.androidx.media3.exoplayer diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt index ff697d6c82..200c896d79 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.service.CashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken +import com.vitorpamplona.amethyst.service.cashu.CashuParser +import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.ui.components.GenericLoadable import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking @@ -38,7 +38,7 @@ class CashuBTest { @Test() fun parseCashuA() { runBlocking { - val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded>).loaded[0] + val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded>).loaded[0] assertEquals(cashuTokenA, parsed.token) assertEquals("https://8333.space:3338", parsed.mint) @@ -59,7 +59,7 @@ class CashuBTest { @Test() fun parseCashuB() = runBlocking { - val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded>).loaded + val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded>).loaded assertEquals(cashuTokenB1, parsed[0].token) assertEquals("http://localhost:3338", parsed[0].mint) @@ -84,7 +84,7 @@ class CashuBTest { @Test() fun parseCashuB2() = runBlocking { - val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded>).loaded + val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded>).loaded assertEquals(cashuTokenB2, parsed[0].token) assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt index d00972ce2a..7e3e34a6ec 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt @@ -58,7 +58,6 @@ class DMFileDecryptionTest { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index d891ebecae..ef98f7fa90 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -24,8 +24,9 @@ import android.graphics.Bitmap import android.graphics.Color import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.ImageDownloader @@ -37,6 +38,8 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.utils.sha256.sha256 import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail @@ -48,6 +51,7 @@ import okhttp3.OkHttpClient import org.junit.Assert import org.junit.Ignore import org.junit.Test +import org.junit.runner.Request.method import org.junit.runner.RunWith import java.io.ByteArrayOutputStream import kotlin.random.Random @@ -55,10 +59,17 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) class ImageUploadTesting { companion object { - val account = - Account( - AccountSettings(KeyPair()), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + val accountSettings = AccountSettings(KeyPair()) + val signer = NostrSignerInternal(accountSettings.keyPair) + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val cache = LocalCache + + val blossomServerListState = + BlossomServerListState( + signer = signer, + cache = cache, + scope = scope, + settings = accountSettings, ) } @@ -107,7 +118,7 @@ class ImageUploadTesting { sensitiveContent = null, serverBaseUrl = server.baseUrl, okHttpClient = { client }, - httpAuth = account::createBlossomUploadAuth, + httpAuth = blossomServerListState::createBlossomUploadAuth, context = InstrumentationRegistry.getInstrumentation().targetContext, ) @@ -135,25 +146,27 @@ class ImageUploadTesting { { client }, ) - val paylod = getBitmap() - val inputStream = paylod.inputStream() + val payload = getBitmap() + val inputStream = payload.inputStream() val result = Nip96Uploader() .upload( inputStream = inputStream, - length = paylod.size.toLong(), + length = payload.size.toLong(), contentType = "image/png", alt = null, sensitiveContent = null, server = serverInfo, okHttpClient = { client }, onProgress = {}, - httpAuth = account::createHTTPAuthorization, + httpAuth = { url, method, body -> + signer.sign(HTTPAuthorizationEvent.build(url, method, body)) + }, context = InstrumentationRegistry.getInstrumentation().targetContext, ) val url = result.url!! - val size = result.size + val size = result.size?.toInt() val dim = result.dimension val hash = result.sha256 @@ -179,7 +192,7 @@ class ImageUploadTesting { assertEquals("${server.name}: Invalid dimensions", it.dim.toString(), dim.toString()) } if (size != null) { - assertEquals("${server.name}: Invalid size", it.size.toString(), size) + assertEquals("${server.name}: Invalid size", it.size, size) } }, onFailure = { fail("${server.name}: It should not fail") }, @@ -221,6 +234,7 @@ class ImageUploadTesting { testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96)) } + @Ignore("Not Working anymore") @Test() fun testNostrBuild() = runBlocking { @@ -235,6 +249,7 @@ class ImageUploadTesting { } @Test() + @Ignore("Not Working anymore") fun testVoidCat() = runBlocking { testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96)) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt index 0e38d73796..d039911477 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt @@ -25,17 +25,15 @@ import com.vitorpamplona.amethyst.service.ots.OkHttpBitcoinExplorer import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient -import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class OkHttpOtsTest { @@ -49,30 +47,30 @@ class OkHttpOtsTest { val resolver = OtsResolver( OkHttpBitcoinExplorer( - OkHttpBitcoinExplorer.MEMPOOL_API_URL, + baseAPI = OkHttpBitcoinExplorer.MEMPOOL_API_URL, client = OkHttpClient.Builder().build(), - otsCache, + cache = otsCache, ), OkHttpCalendarBuilder { OkHttpClient.Builder().build() }, ) @Test fun verifyNostrEvent() { - val ots = EventMapper.fromJson(otsEvent) as OtsEvent + val ots = JsonMapper.fromJson(otsEvent) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(1707688818L, ots.verify(resolver)) } @Test fun verifyNostrEvent2() { - val ots = EventMapper.fromJson(otsEvent2) as OtsEvent + val ots = JsonMapper.fromJson(otsEvent2) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(1706322179L, ots.verify(resolver)) } @Test fun verifyNostrPendingEvent() { - val ots = EventMapper.fromJson(otsPendingEvent) as OtsEvent + val ots = JsonMapper.fromJson(otsPendingEvent) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(null, ots.verify(resolver)) } @@ -80,20 +78,15 @@ class OkHttpOtsTest { @Test fun createOTSEventAndVerify() { val signer = NostrSignerInternal(KeyPair()) - var ots: OtsEvent? = null - - val countDownLatch = CountDownLatch(1) val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver) - signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) { - ots = it - countDownLatch.countDown() - } + val ots = + runBlocking { + signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) + } - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - println(ots!!.toJson()) + println(ots.toJson()) println(resolver.info(ots.otsByteArray())) // Should not be valid because we need to wait for confirmations diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 61497dfb17..f1d881ce5b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -25,10 +25,14 @@ 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.service.location.LocationState +import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket 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 +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase @@ -36,92 +40,116 @@ import junit.framework.TestCase.assertEquals import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ThreadDualAxisChartAssemblerTest { - val db = - """ - [ - {"id":"741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c","pubkey":"d85c99afd244911e0aaf800cbea4221df557f06f8a4ff2cbe84b24e0b9e728fc","created_at":1684674845,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Getting my head around this","sig":"069874040bac26a219777fc0f90b8f4df71e38c30e3e6a953d53222499d8e0c5a8f32c6b4204d14eb335bb654f01c5610372d9dc00062284b8e0f2bb98c7ed85"}, - {"id":"22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1683571810,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test","sig":"f6d3bcf0f8e07d06720a2527f95910ee5a66aa889dd9261514921a155bbc3e9a3c7d721f5a2ec948cd795183f85dbe80f2ee2e36663f9d1de33fc03274ccb9d9"}, - {"id":"14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32","pubkey":"afd563434a737334d69db899e4a32fe38d73a182bb6d1e91d83a2c4c4e04737c","created_at":1682968749,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is most amazing thing yet for nostr!","sig":"3dcbb058b8fa6e6f388f5b1a26d21b888dbc91bb831b0ed80ee30d9d2494fbbbb40dac9daf637b948c3afe1f32c2c662eca8d7acc0ba31f23b89edc8ade99631"}, - {"id":"98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6","pubkey":"83fd07de9b763334cc9d46f2785c2558e6c2eabfe7d0c6ec214667cbaec50d47","created_at":1681995243,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"true if big","sig":"bb7d97e81207096d8615ebb0458c3023fa1b8f3aac463f3f0293ae94e2b90d043ad371067ce984a2ab2ba49cd7432a553f128b8d2d678238551538ad657bd75c"}, - {"id":"36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d","pubkey":"b111d517452f9ef015e16d60ae623a6b66af63024eec941b0653bfee0dd667d4","created_at":1681979196,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"this is wonderful. great work.","sig":"205a46d867c22cbb09c7dcc4351330a95304c0d80540c85b9ea39f7f464ea60df1ef5006c1f93b862536f04d8a7855e4cc2a915d7a01437cd30f7f12c716b239"}, - {"id":"37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642","pubkey":"90b9bec74789688e515125596ab6350bfe646176ac75742275063922c5fea010","created_at":1681944950,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Epic!","sig":"c461b97a3cdc5cf967e6f32c59c94b1b912c296feb1cbb7943c81731e558b10a3674982d6990567260d9fb033f5a3d3db8984b1eb362715d68ba46034a8b1b86"}, - {"id":"8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6","pubkey":"c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11","created_at":1681944377,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is incredible... Bravo, Pablo, and shoutout to Gigi.","sig":"fb1a84d4859742b001b810c98a5e9301bc861fa2d65a711cb21c57f6e62c98f27e5b0c0c6432ef1111bc6cc52aa8cb0ae909d28af50c497d125950bfaeade475"}, - {"id":"53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681945005,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["e","8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11"]],"content":"Thank you, my friend 🤙 ","sig":"3d2fbcefdfac2183601076c8f9e518e05620cefe74cba233684cce931a69b51befa329c94734e434805bd9382ff3dad36e42d1955d4e47c822e973ba69b7377f"}, - {"id":"e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681932060,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"https://nostr.build/p/nb5753.png","sig":"9df6e3872f420ed5225aaa242f1c806b4739f17e265f02ca593a3c31c5fb5cb48705ee6bc0510f81f123527aa96ec4d07adf829362e2196bd7b833deded4c2b8"}, - {"id":"e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681931869,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Very Cool, I started playing around!","sig":"5980b2d0ef114d50c5f45b5d0c55bfc56b16a0a1807774ec2a86c4e352e17e3ec78877c73f2fedfbfd097a018df8e965107d6efd4ad9c0a0e1ecc89332d51cec"}, - {"id":"b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681932193,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f","","root"],["e","e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","","reply"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"]],"content":"love seeing it!!!","sig":"47850c205f1fa6d8836cda347c7d12c4307d35a110629eac48b4fff26600a14a5c5c8a7877b851db062a89e0157be112eae60ba0594525c1cc4d355906368dbb"}, - {"id":"da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931637,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Great thoughts ","sig":"34c81430952508746fd5d5a4c4f7df0f5762aeb17d075a74f7b0daf387a7b8772edf4152731cff6650219e1aa9f8f63924410baa03091a698af0513cbf57e0af"}, - {"id":"b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931560,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"I like comments ","sig":"6915f4c2a08622339957495931eabc37f8d0c50ab1f3ec42327b67d15e50f50ff4bc302848b606e6d7c170696f8d744e6f896223f30ecb39a3adffe58020ff6f"}, - {"id":"fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681834615,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is exactly why I built this. I am enamored by this idea.","sig":"bdd1b3532efa3badeb3807fe74d31c25f17c84bbe39fe3c692d3d73efa39408089843ebb3c049840f2d525e6849f8254179a888bedc776ee7ad5e6997dc10c44"}, - {"id":"d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5","pubkey":"39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868","created_at":1680618462,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Unfortunately when boosting this it gets broken 🥹\n#[1]","sig":"c4a4197df4fec008f2f565170d09c46f0b9a7217ed90432cb53d1dddf70ad8520722b6e4f04ec9e52dd219ee01387c6191f449cd10a6c2488b1d2866424b7a8a"}, - {"id":"8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6","pubkey":"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93","created_at":1680636068,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["e","d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"we early ","sig":"b8026e43c61b491014915caf79289968b87e52aa87a487ebecbf6cb2b3c07d460f2ab1a86da99b1c5410a0f5d14e77cf27f78c56bf1ab709f0d616ad2711de78"}, - {"id":"e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025613,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 3","sig":"1a34f9bb9eca624f4e0c3de2ad4ad7d9c915cd16e3f1e41066a69d3799a5bb19689a8e0d894e00649cae7f7d27beadbab2b04d4083f7f2ce68e5ee65af229f55"}, - {"id":"ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933","pubkey":"95181f816dc80a141e9158a2a398bec37f580f4309633321a1ca53757bf08794","created_at":1690373626,"kind":1,"tags":[["e","e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e"],["p","d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9"]],"content":"I see your tests. 1-3 🫡","sig":"cc8d3af65578cc2436878fb5dbf3ac27751b50f91b02dd944094e9f9f2965193e32fb20ce68cfe5555ecfb4b13fd06b9cdb673dc71e5db47199b8e6cde2d2e60"}, - {"id":"b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025593,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 2","sig":"0226d5836991836ba105cd483def0baa4255bad19a8213ae483c0168efb4de6e493233ed1fdc83e038d5227e0254dc0b461e7a7fa8032a7eada56b9840e8762f"}, - {"id":"e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025573,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test","sig":"ba66f1123e93a70645f3f01814378d23609cc2fda38f083410e0e06b037dc9fc45d139b690e2e892278f768c97a03e02e0a9cd0a8b385271db180e1e8838784d"}, - {"id":"4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c","pubkey":"84142dede040f7e0852ba867cfa921986b7b7e92c4b7bc5c72fbd2a4577545e4","created_at":1688308094,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"testing","sig":"9f991643229822577c967d6d71831a6d98993dbbd3613b04abda0907a944b8eb20f6f8477bc8e922308e2e2135243ab78a31a93f285ee6215ac5536141a7d95d"}, - {"id":"9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990252,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"Running zapthreads","sig":"1cd2209548f5a791807e4a8aeafbf3a2fb28cee013ad5f444f86dffd448668c05480d7714b892314f5fa50b7c65a23708362c1d5b0cb669c64cfb06ca8486ba9"}, - {"id":"5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687996513,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","","reply"]],"content":"Testing nested comments","sig":"335f9969d23e28fef45adfda63da236f1d4d72dfb5873fc78961d47ee948e9333632ce8a45df3c09fc431cf8d29d2fb99bea9c9a4b3502465b05a0328d563231"}, - {"id":"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26","pubkey":"45c41f21e1cf715fa6d9ca20b8e002a574db7bb49e96ee89834c66dac5446b7a","created_at":1680618145,"kind":1,"tags":[["e","1a1fe6c838400f3347f97a9adce08c10068f3bc35a279520c911b025c387b061",""],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",""],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599",""],["title","Purple Text, Orange Highlights"],["summary","Using nostr as a base layer to create, consume, and monetize long-form content."],["image","https://dergigi.com/assets/images/reader.jpg"],["published_at","1680613802"]],"content":"good read. the highlighted part to be a shareable event 1 idea seems interesting, might explore it on flycat","sig":"ee913e997b907a03300616c36cffa12d2362932bd5e249cde39a44ec1250c7622703dddac167b093c3e3a28d41ebfeb9aed9d663d82bea9f8deaed4e342248dc"}, - {"id":"a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa","pubkey":"619af6a60b3fe4c733aaca061c522cc9c7cf1d87ef4c908facc5ed936d3bdf23","created_at":1681941926,"kind":1,"tags":[["p","330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac"],["e","d7ef8220e3521779e2e0c3ff0afb9738f241e44ace8ed1d5237cab36330a69d0"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Good or bad time?","sig":"5629e21769b018c7f050ad35e20c3926e5f9c415ebcd54656e874cff7948a9117de6fb32d18f993b7c96f480f3e4baffb079a3db7c126c74b0188006bba41768"}, - {"id":"c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285","pubkey":"126df36019a793922eae86bfcfedc5240b68d67953892237e3eb500ab92140bf","created_at":1681911407,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","f2610be11393f884c020119316d3304bc06c48ab01f9cbcf5a5a8290031bf20e"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Cool","sig":"62240add277f55e896ae24276fdae60f1580be9de4889e546fd2141b73e07b3e48f225a57914a5e0e5a2fd87570a1dfe1af4f869b355a7c455aa77f395c10bdb"}, - {"id":"6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821926,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"eeba84f251d8d79e0f16213b766aee8079a3a5018407c3f7d1e8161c762e6a735bb695ac54898e04d7abe245ec12f24d680927351d37a6f31557344adeebd3c2"}, - {"id":"6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681755803,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","9defafc0783f16b2ef9ac02d2e808a103d5d38f12905b7840420634af67d0821"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test1","sig":"17c2489ee28dc32df68ebf87ced6f5f9af0e3f091b31298fece408acba43236bc6c9f50d756f61bcf46a204e043141535e0cd281d541146cbfac259814cd2cbe"}, - {"id":"e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648013,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"powerful quote 👀","sig":"eda9e5565000892dacab78469936d96aad7fb0dc0828630563b8ccb731f08e704eacfefff246b6bb668332e046f5650f2a9e4186c108f07cba07eefc775c6c58"}, - {"id":"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821952,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 7","sig":"e2c35766479d549700f4dbaeae385cfdacee1275ddf24777643ca7d7335c6d1baf7f89ac25d1dc90688bc49f8d2f5e4857d2234035291628a0c0ac760485bab0"}, - {"id":"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821936,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"875339b3e666fb1013d6563552997dcdd02e87b1f290ad25b0d7dd085d2f87dd10a4e6c20dfe3fc109d1809733b93675505341fe149e4399c42371c743fafc73"}, - {"id":"674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648340,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"nested reply","sig":"f0f4fdd2d34e59255137c457b9d42fa9b5357e824f3154340de964616f6daf21e3b03491363b0060f97c5061445d7b2854eed328c5cced751b2b480e5ce8ce9a"}, - {"id":"d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648345,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"another one","sig":"dd732eb380542dd8068449d5617bb8b6c1dfe1495c9facbd46aad3ca9b084122479df30861b8e85e13e7b0c9bf9bce589b7fc062daa086c1ff8a0b1d1ffc7aa7"}, - {"id":"00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648351,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"yet another one","sig":"6332c8a441439487898fd5b423e42dde7d1d7aadfcf9b36defd78d76d1685ba099ac86ae00a1dac6ed5e8a166422d59bbb93bfccb2596dc1c5461efcfcf345b4"}, - {"id":"87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990701,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","","reply"]],"content":"and another one more","sig":"6a385fb259ff40d9b6bd8bb1d5a2e20057ec1c5b5ed2c307fbc40e4a96d4c191b80b6ea3f3f5748c907d4b0e6e64a92210a0be2889af856ad084ec6806f3cbd8"}, - {"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"}, - {"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"} - ] - """.trimIndent() + companion object { + val keyPair = KeyPair() + val account = + Account( + settings = AccountSettings(keyPair = keyPair), + signer = NostrSignerInternal(keyPair), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), + cache = LocalCache, + client = + NostrClient( + OkHttpWebSocket.Builder { + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + }, + CoroutineScope(Dispatchers.IO + SupervisorJob()), + ), + ) - val header = - """ - { - "content": "Not too long ago, I tried to paint a picture of what\na [vision for a value-enabled web][vew]\ncould look like. Now, only a couple of months later,\nall this stuff is being built. On nostr, and on lightning. Orange and\npurple, a match made in heaven.\n\nIt goes without saying that I'm beyond delighted. What a time to be alive!\n\n## nostr\n\nHere's the thing that nostr got right, and it's the same thing that\nBitcoin got right: information is easy to spread and hard to stifle.[^fn-stifle]\nInformation can be copied quickly and perfectly, which is, I believe,\nthe underlying reason for its desire to be free.\n\n[^fn-stifle]: That's a [Satoshi quote][stifle], of course: \"Bitcoin's solution is to use a peer-to-peer network to check for double-spending. In a nutshell, the network works like a distributed timestamp server, stamping the first transaction to spend a coin. It takes advantage of the nature of information being easy to spread but hard to stifle.\"\n\n[stifle]: https://satoshi.nakamotoinstitute.org/posts/p2pfoundation/1/\n\nEasy to spread, hard to stifle. That's the base reality of the nature\nof information. As always, the smart thing is to work with nature, not\nagainst it.[^1] That's what's beautiful about the orange coin and\nthe purple ostrich: both manage to work with the peculiarities of\ninformation, not against them. Both realize that information can and should be\ncopied, as it can be perfectly read and easily spread, always. Both understand\nthat resistance to censorship comes from writing to many places, making the cost\nof deletion prohibitive.\n\n> Information does not just want to be free,\n> it longs to be free. Information expands to fill the available\n> storage space. Information is Rumor's younger, stronger cousin;\n> Information is fleeter of foot, has more eyes, knows more, and\n> understands less than Rumor.\n>\n> Eric Hughes, [A Cypherpunk's Manifesto][manifesto]\n\n[manifesto]: https://nakamotoinstitute.org/static/docs/cypherpunk-manifesto.txt\n\nNostr is quickly establishing itself as a base layer for information exchange,\none that is identity-native and value-enabled. It is distinctly different from\nsystems that came before it, just like Bitcoin is distinctly different from\nmonies that came before it.\n\nAs of today, the focus of nostr is mostly on short text notes, the so-called\n\"type 1\" events more commonly known as *tweets*.[^fn-kinds] However, as you should be aware\nby now, nostr is way more than just an alternative to twitter. It is a new\nparadigm. Change the note kind from `1` to `30023` and you don't have an\nalternative to Twitter, but a replacement for Medium, Substack, and all the\nother long-form platforms. I believe that special-purpose clients that focus on\ncertain content types will emerge over time, just like we have seen the\nemergence of special-purpose platforms in the Web 2.0 era. This time, however,\nthe network effects are cumulative, not separate. A new paradigm.\n\nLet me now turn to one such special-purpose client, a nostr-based reading app.\n\n[^fn-kinds]: Refer to the various NIPs to discover the multitude of [event kinds][kinds] defined by the protocol.\n\n[kinds]: https://github.com/nostr-protocol/nips#event-kinds\n[nip23]: https://github.com/nostr-protocol/nips/blob/master/23.md\n\n## Reading\n\nI'm constantly surprised that, even though most people do read a lot\nonline, very few people seem to have a reading workflow or reading\ntools.\n\nWhy that is is anyone's guess, but maybe the added value of such tools\nis not readily apparent. You can just read the stuff right there, on the\nad-ridden, dead-ugly site, right? Why should you sign up for another\nsite, use another app, or bind yourself to another closed platform?\n\nThat's a fair point, but the success of Medium and Substack shows that\nthere is an appetite for clean reading and writing, as well as providing\navenues for authors to get paid for their writing (and a willingness of\nreaders to support said authors, just because).\n\nThe problem is, of course, that all of these platforms are *platforms*,\nwhich is to say, walled gardens that imprison readers and writers alike.\nWorse than that: they are fiat platforms, which means that\npermissionless value-flows are not only absent from their DNA, they are\noutright impossible.[^2]\n\nNostr fixes this.\n\n![Nostriches like to read, or so I've heard](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostrich-reading-a-newspaper.jpg)\n\nThe beauty of nostr is that it is not a platform. It's a protocol,\nwhich means that you don't have to sign up for it---you can create an\nidentity yourself. You don't have to ask for permission; you just *do*,\nwithout having to rely on the benevolence of whatever dictator is in\ncharge of the platform right now.\n\nNostr is *not* a platform, and yet, powerful tools and services can be\nbuilt and monetized on top of it. This is good for users, good for\nservice providers, and good for the network(s) at large. Win-win-win.\n\nSo what am I talking about, exactly? How can nostr improve everyone's\nreading (and writing) experience?\n\nAllow me to paint a (rough) picture of what I have in mind. Nostr\nalready supports private and public bookmarks, so let's start from\nthere.\n\nImagine a special-purpose client that scans all your bookmarks for long-form\ncontent.[^fn-urls] Everything that you marked to be read later is shown in an orderly\nfashion, which is to say searchable, sortable, filterable, and displayed without\ndistractions. Voilà, you have yourself a reading app. That's, in essence, how\nPocket, Readwise, and other reading apps work. But all these apps are walled\ngardens without much interoperability and without direct monetization.\n\n[^fn-urls]: In the nostr world long-form content is simply markdown as defined in [NIP-23][nip23], but it could also be a link to an article or PDF, which in turn could get [converted into markdown][readability] and posted as an event to a special relay.\n\n[readability]: https://github.com/mozilla/readability\n\nBitcoin fixes the direct monetization part.[^fn-v4v] Nostr fixes the interoperability part.\n\n[^fn-v4v]: ...because Bitcoin makes [V4V][busking] practical. (Paywalls are not the way.)\n\nAlright, we got ourselves a boring reading app. Great. Now, imagine that\nusers are able to highlight passages. These highlights, just like\nbookmarks now, could be private or public. When shared publicly,\nsomething interesting emerges: an overlay on existing content, a lens on\nthe written Web. In other words: *swarm highlights*.\n\nImagine a visual overlay of all public highlights, automatically shining\na light on what the swarm of readers found most useful, insightful,\nfunny, etc.\n\n![Swarm Highlights](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/highlights.png)\n\nFurther, imagine the possibility of sharing these highlights as a \"type 1\" event\nwith one click, automatically tagging the highlighter(s)---as well as the\nauthor, of course---so that eventual sat-flows can be split and forwarded\nautomatically.\n\n![Automated value splits](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/sat-flows.png)\n\nVoilà, you have a system that allows for value to flow back to those who\nprovide it, be it authors, editors, curators, or readers that willingly\nslog through the information jungle to share and highlight the best\nstuff (which is a form of curation, of course).\n\nZaps make nostr a defacto address book[^fn-pp] of payment information, which is\nto say lightning addresses, as of now. Thanks to [nostr wallet connect][nwc] (among\nother developments), sending sats ~~will soon be~~ is already as\nfrictionless as leaving a like.\n\n[^fn-pp]: The Yellow Pages are dead, long live [The Purple Pages](http://purplepag.es/)!\n\nValue-for-value and participatory payment flows are something that\ntraditional reading apps desperately lack, be it Pocket, Instapaper,\nReadwise, or the simple reading mode that is part of every browser.\n\nA neat side-effect of a more structured way to share passages of text is\nthat it enables semi-structured discussions around said\npassages---which could be another useful overlay inside\nspecial-purpose clients, providing context and further insights.[^5]\n\nFurther, imagine the option of seamlessly switching from text-on-screen\nto text-to-speech, allowing the user to stream sats if desired, as\nPodcasting 2.0 clients already do.[^3]\n\nImagine user-built curations of the best articles of the week, bundled\nneatly for your reading pleasure, incentivized by a small value split\nthat allows the curator to participate in the flow of sats.\n\nYou get the idea.\n\nI'm sure that the various implementation details will be hashed out,\nbut as I see it, 90% of the stuff is already there. Maybe we'll need\nanother NIP or two, but I don't see a reason why this can't be\nbuilt---and, more importantly: I don't see a reason why it wouldn't\nbe sustainable for everyone involved.\n\nMost puzzle pieces are already there, and the rest of them can probably\nbe implemented by custom event types. From the point of view of nostr,\nmost everything is an event: bookmarks are events, highlights are\nevents, marking something as read is an event, and sharing an excerpt or\na highlight is an event. Public actions are out in the open, private\nactions are encrypted, the data is not in a silo, and everyone wins.\nEspecially the users, those who are at the edge of the network and\nusually lose out on the value generated.\n\nIn this case, the reading case, the users are mostly \"consumers\" of\ncontent. What changes from the producing perspective, the perspective of\nthe writer?\n\n## Writing\n\nBack to the one thing that nostr got right: information is easy to\nspread but hard to stifle. In addition to that, digital information can\nbe copied perfectly, which is why it shouldn't matter where stuff is\npublished in the first place.\n\nAllow me to repeat this point in all caps, for emphasis: **IT SHOULD NOT\nMATTER WHERE INFORMATION IS PUBLISHED**, and, maybe even more\nimportantly, it shouldn't matter if it is published in a hundred\ndifferent places at once.[^fn-torrents]\n\nWhat matters is trust and accuracy, which is to say, digital signatures\nand reputation. To translate this to nostr speak: because every event is\nsigned by default, as long as you trust the person behind the signature,\nit doesn't matter from which relay the information is fetched.\n\nThis is already true (or mostly true) on the regular web. Whether you\nread the internet archive version of an article or the version that is\npublished by an online magazine, the version on the author's website,\nor the version read by some guy that has read more about Bitcoin than\nanyone else you know[^fn-guy]---it's all the same, essentially. What matters\nis the information itself.\n\n[^fn-guy]: There is only one such guy, as we all know, and it's this Guy: nostr:npub1h8nk2346qezka5cpm8jjh3yl5j88pf4ly2ptu7s6uu55wcfqy0wq36rpev\n\nPractically speaking, the source of truth in a hypernostrized world is---you\nguessed it---an event. An event signed by the author, which allows for\nthe information to be wrapped in a tamper-proof manner, which in turn\nallows the information to spread far and wide---without it being\nhosted in one place.\n\nThe first clients that focus on long-form content already exist, and I expect\nmore clients to pop up over time.[^4] As mentioned before, one could easily\nimagine [prism-like value splits][prism] seamlessly integrated into these\nclients, splitting zaps automatically to compensate writers, editors,\nproofreaders, and illustrators in a V4V fashion. Further, one could imagine\nvarious compute-intensive services built into these special-purpose clients,\nsuch as GPT Ghostwriters, or writing aids such as Grammarly and the like. All\nthese services could be seamlessly paid for in sats, without the requirement of\nany sign-ups or the gathering of any user data. That's the beauty of [money\nproper][rediscovery].\n\n![A clean and simple reading and writing interface](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostr-reader-and-writer.png)\n\nPlagiarism is one issue that needs to be dealt with, of course. Humans\nare greedy, and some humans are assholes. Neither bitcoin nor nostr\nfixes this. However, while plagiarism detection is not necessarily\ntrivial, it is also not impossible, especially if most texts are\npublished on nostr first. Nostr-based publishing tools allow for\nOpenTimestamp attestations thanks\nto [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md),\nwhich in turn allows for plagiarism detection based on \"first seen\"\nlookups.\n\nThat's just one way to deal with the problem, of course. In any case,\nI'm confident that we'll figure it out.\n\n## Value\n\nI believe that in the open ~~attention~~ information economy we find\nourselves in, value will mostly derive from effective curation,\ndissemination, and transmission of information, *not* the exclusive\nownership of it.\n\nAlthough it is still early days,\nthe [statistics](https://stats.podcastindex.org/v4v) around Podcasting\n2.0 and [nostr zaps](https://zaplife.lol/) clearly show that (a) people\nare willing to monetarily reward content they care about, and (b) the\nwillingness to send sats *increases* as friction *decreases*.\n\nThe ingenious thing about boostagrams and zaps is that they are direct\nand visible, which is to say, public and interactive. They are neither\nregular transactions nor simple donations---they are something else\nentirely. An unforgable value signal, a special form of gratitude and\nappreciation.\n\nContrast that with a link to Paypal or Patreon: impersonal, slow,\nindirect, and friction-laden. It's the opposite of a super-charged\ninteraction.\n\nWhile today's information jungle increasingly presents itself in the\nform of (short) videos and (long-form) audio, I believe that we will see\na renaissance of the written word, especially if we manage to move away\nfrom an economy built around attention, towards an economy built upon\nvalue and insight.\n\nThe orange future now has a purple hue, and I believe that it will be as\nbright as ever. We just have a lot of building to do.\n\n---\n\n## Further Reading\n\n- [A Vision for a Value-Enabled Web][vew]\n- [The Freedom of Value][busking]\n- [The Rediscovery of Money][prism]\n- [Lightning Prisms][rediscovery]\n\n[vew]: https://dergigi.com/vew\n[prism]: https://dergigi.com/prism\n[rediscovery]: https://dergigi.com/rediscovery\n[busking]: https://dergigi.com/busking\n\n## NIPs and Resources\n\n- [Nostr Resources][nr]\n- [value4value.info](https://value4value.info/)\n- [nips.be](https://nips.be/)\n- [NIP-23: Long-form content](https://github.com/nostr-protocol/nips/blob/master/23.md)\n- [NIP-57: Event-specific zap markers](https://github.com/nostr-protocol/nips/blob/master/57.md)\n- [NIP-47: Nostr Wallet Connect](https://github.com/getAlby/nips/blob/master/47.md)\n- [NIP-03: OpenTimestamps attestations for events](https://github.com/nostr-protocol/nips/blob/master/03.md)\n\nOriginally published on [dergigi.com](https://dergigi.com/reader)\n\n---\n\n[^1]: Paywalls work against this nature, which is why I consider them misguided at best and incredibly retarded at worst.\n\n[^2]: Fiat doesn't work for the [value-enabled web][vew], as fiat rails can never be open and permissionless. Digital fiat is never money. It is---and always will be---[credit][rediscovery].\n\n[^3]: Whether the recipient is a text-to-speech service provider or a human narrator doesn't even matter too much, sats will flow just the same.\n\n[^4]: [BlogStack](https://blogstack.io/) and [Habla](https://habla.news/) being two of them.\n\n[^5]: Use a URI as the discussion base (instead of a highlight), and you got yourself a [Disqus](https://disqus.com/) in purple feathers!\n\n[^fn-torrents]: That's what torrents got right, and [ipfs] for that matter.\n\n[nr]: https://nostr-resources.com\n[nwc]: https://nwc.getalby.com/\n[ipfs]: https://fiatjaf.com/d5031e5b.html\n", - "created_at": 1680614039, - "id": "9517aa60334990596fdea4493c1bde429c55e9cefb84d8d88a6758c4e3ebcabc", - "kind": 30023, - "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", - "sig": "c8c8c7ac8c288f6a6e1090e0e5f7eed85daaaba03eeab0a4936535f95ce066c08723bedcfb80a6af4941a9fafb9fe351950b9376d13ff0e81030cd4391840f04", - "tags": [ - ["d","1680612926599"], - ["title","Purple Text, Orange Highlights"], - ["summary","Using nostr as a base layer to create, consume, and monetize long-form content."], - ["published_at","1680613802"], - ["t","nostr"], - ["t","reading"], - ["t","writing"], - ["t","readability"], - ["t","highlights"], - ["t","pocket"], - ["t","instapaper"], - ["t","readwise"], - ["t","disqus"], - ["t","v4v"], - ["t","value4value"], - ["image","https://dergigi.com/assets/images/reader.jpg"], - ["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"] - ] - } - """.trimIndent() + val db = + """ + [ + {"id":"741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c","pubkey":"d85c99afd244911e0aaf800cbea4221df557f06f8a4ff2cbe84b24e0b9e728fc","created_at":1684674845,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Getting my head around this","sig":"069874040bac26a219777fc0f90b8f4df71e38c30e3e6a953d53222499d8e0c5a8f32c6b4204d14eb335bb654f01c5610372d9dc00062284b8e0f2bb98c7ed85"}, + {"id":"22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1683571810,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test","sig":"f6d3bcf0f8e07d06720a2527f95910ee5a66aa889dd9261514921a155bbc3e9a3c7d721f5a2ec948cd795183f85dbe80f2ee2e36663f9d1de33fc03274ccb9d9"}, + {"id":"14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32","pubkey":"afd563434a737334d69db899e4a32fe38d73a182bb6d1e91d83a2c4c4e04737c","created_at":1682968749,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is most amazing thing yet for nostr!","sig":"3dcbb058b8fa6e6f388f5b1a26d21b888dbc91bb831b0ed80ee30d9d2494fbbbb40dac9daf637b948c3afe1f32c2c662eca8d7acc0ba31f23b89edc8ade99631"}, + {"id":"98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6","pubkey":"83fd07de9b763334cc9d46f2785c2558e6c2eabfe7d0c6ec214667cbaec50d47","created_at":1681995243,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"true if big","sig":"bb7d97e81207096d8615ebb0458c3023fa1b8f3aac463f3f0293ae94e2b90d043ad371067ce984a2ab2ba49cd7432a553f128b8d2d678238551538ad657bd75c"}, + {"id":"36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d","pubkey":"b111d517452f9ef015e16d60ae623a6b66af63024eec941b0653bfee0dd667d4","created_at":1681979196,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"this is wonderful. great work.","sig":"205a46d867c22cbb09c7dcc4351330a95304c0d80540c85b9ea39f7f464ea60df1ef5006c1f93b862536f04d8a7855e4cc2a915d7a01437cd30f7f12c716b239"}, + {"id":"37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642","pubkey":"90b9bec74789688e515125596ab6350bfe646176ac75742275063922c5fea010","created_at":1681944950,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Epic!","sig":"c461b97a3cdc5cf967e6f32c59c94b1b912c296feb1cbb7943c81731e558b10a3674982d6990567260d9fb033f5a3d3db8984b1eb362715d68ba46034a8b1b86"}, + {"id":"8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6","pubkey":"c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11","created_at":1681944377,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is incredible... Bravo, Pablo, and shoutout to Gigi.","sig":"fb1a84d4859742b001b810c98a5e9301bc861fa2d65a711cb21c57f6e62c98f27e5b0c0c6432ef1111bc6cc52aa8cb0ae909d28af50c497d125950bfaeade475"}, + {"id":"53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681945005,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["e","8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11"]],"content":"Thank you, my friend 🤙 ","sig":"3d2fbcefdfac2183601076c8f9e518e05620cefe74cba233684cce931a69b51befa329c94734e434805bd9382ff3dad36e42d1955d4e47c822e973ba69b7377f"}, + {"id":"e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681932060,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"https://nostr.build/p/nb5753.png","sig":"9df6e3872f420ed5225aaa242f1c806b4739f17e265f02ca593a3c31c5fb5cb48705ee6bc0510f81f123527aa96ec4d07adf829362e2196bd7b833deded4c2b8"}, + {"id":"e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681931869,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Very Cool, I started playing around!","sig":"5980b2d0ef114d50c5f45b5d0c55bfc56b16a0a1807774ec2a86c4e352e17e3ec78877c73f2fedfbfd097a018df8e965107d6efd4ad9c0a0e1ecc89332d51cec"}, + {"id":"b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681932193,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f","","root"],["e","e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","","reply"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"]],"content":"love seeing it!!!","sig":"47850c205f1fa6d8836cda347c7d12c4307d35a110629eac48b4fff26600a14a5c5c8a7877b851db062a89e0157be112eae60ba0594525c1cc4d355906368dbb"}, + {"id":"da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931637,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Great thoughts ","sig":"34c81430952508746fd5d5a4c4f7df0f5762aeb17d075a74f7b0daf387a7b8772edf4152731cff6650219e1aa9f8f63924410baa03091a698af0513cbf57e0af"}, + {"id":"b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931560,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"I like comments ","sig":"6915f4c2a08622339957495931eabc37f8d0c50ab1f3ec42327b67d15e50f50ff4bc302848b606e6d7c170696f8d744e6f896223f30ecb39a3adffe58020ff6f"}, + {"id":"fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681834615,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is exactly why I built this. I am enamored by this idea.","sig":"bdd1b3532efa3badeb3807fe74d31c25f17c84bbe39fe3c692d3d73efa39408089843ebb3c049840f2d525e6849f8254179a888bedc776ee7ad5e6997dc10c44"}, + {"id":"d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5","pubkey":"39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868","created_at":1680618462,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Unfortunately when boosting this it gets broken 🥹\n#[1]","sig":"c4a4197df4fec008f2f565170d09c46f0b9a7217ed90432cb53d1dddf70ad8520722b6e4f04ec9e52dd219ee01387c6191f449cd10a6c2488b1d2866424b7a8a"}, + {"id":"8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6","pubkey":"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93","created_at":1680636068,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["e","d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"we early ","sig":"b8026e43c61b491014915caf79289968b87e52aa87a487ebecbf6cb2b3c07d460f2ab1a86da99b1c5410a0f5d14e77cf27f78c56bf1ab709f0d616ad2711de78"}, + {"id":"e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025613,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 3","sig":"1a34f9bb9eca624f4e0c3de2ad4ad7d9c915cd16e3f1e41066a69d3799a5bb19689a8e0d894e00649cae7f7d27beadbab2b04d4083f7f2ce68e5ee65af229f55"}, + {"id":"ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933","pubkey":"95181f816dc80a141e9158a2a398bec37f580f4309633321a1ca53757bf08794","created_at":1690373626,"kind":1,"tags":[["e","e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e"],["p","d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9"]],"content":"I see your tests. 1-3 🫡","sig":"cc8d3af65578cc2436878fb5dbf3ac27751b50f91b02dd944094e9f9f2965193e32fb20ce68cfe5555ecfb4b13fd06b9cdb673dc71e5db47199b8e6cde2d2e60"}, + {"id":"b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025593,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 2","sig":"0226d5836991836ba105cd483def0baa4255bad19a8213ae483c0168efb4de6e493233ed1fdc83e038d5227e0254dc0b461e7a7fa8032a7eada56b9840e8762f"}, + {"id":"e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025573,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test","sig":"ba66f1123e93a70645f3f01814378d23609cc2fda38f083410e0e06b037dc9fc45d139b690e2e892278f768c97a03e02e0a9cd0a8b385271db180e1e8838784d"}, + {"id":"4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c","pubkey":"84142dede040f7e0852ba867cfa921986b7b7e92c4b7bc5c72fbd2a4577545e4","created_at":1688308094,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"testing","sig":"9f991643229822577c967d6d71831a6d98993dbbd3613b04abda0907a944b8eb20f6f8477bc8e922308e2e2135243ab78a31a93f285ee6215ac5536141a7d95d"}, + {"id":"9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990252,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"Running zapthreads","sig":"1cd2209548f5a791807e4a8aeafbf3a2fb28cee013ad5f444f86dffd448668c05480d7714b892314f5fa50b7c65a23708362c1d5b0cb669c64cfb06ca8486ba9"}, + {"id":"5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687996513,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","","reply"]],"content":"Testing nested comments","sig":"335f9969d23e28fef45adfda63da236f1d4d72dfb5873fc78961d47ee948e9333632ce8a45df3c09fc431cf8d29d2fb99bea9c9a4b3502465b05a0328d563231"}, + {"id":"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26","pubkey":"45c41f21e1cf715fa6d9ca20b8e002a574db7bb49e96ee89834c66dac5446b7a","created_at":1680618145,"kind":1,"tags":[["e","1a1fe6c838400f3347f97a9adce08c10068f3bc35a279520c911b025c387b061",""],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",""],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599",""],["title","Purple Text, Orange Highlights"],["summary","Using nostr as a base layer to create, consume, and monetize long-form content."],["image","https://dergigi.com/assets/images/reader.jpg"],["published_at","1680613802"]],"content":"good read. the highlighted part to be a shareable event 1 idea seems interesting, might explore it on flycat","sig":"ee913e997b907a03300616c36cffa12d2362932bd5e249cde39a44ec1250c7622703dddac167b093c3e3a28d41ebfeb9aed9d663d82bea9f8deaed4e342248dc"}, + {"id":"a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa","pubkey":"619af6a60b3fe4c733aaca061c522cc9c7cf1d87ef4c908facc5ed936d3bdf23","created_at":1681941926,"kind":1,"tags":[["p","330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac"],["e","d7ef8220e3521779e2e0c3ff0afb9738f241e44ace8ed1d5237cab36330a69d0"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Good or bad time?","sig":"5629e21769b018c7f050ad35e20c3926e5f9c415ebcd54656e874cff7948a9117de6fb32d18f993b7c96f480f3e4baffb079a3db7c126c74b0188006bba41768"}, + {"id":"c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285","pubkey":"126df36019a793922eae86bfcfedc5240b68d67953892237e3eb500ab92140bf","created_at":1681911407,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","f2610be11393f884c020119316d3304bc06c48ab01f9cbcf5a5a8290031bf20e"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Cool","sig":"62240add277f55e896ae24276fdae60f1580be9de4889e546fd2141b73e07b3e48f225a57914a5e0e5a2fd87570a1dfe1af4f869b355a7c455aa77f395c10bdb"}, + {"id":"6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821926,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"eeba84f251d8d79e0f16213b766aee8079a3a5018407c3f7d1e8161c762e6a735bb695ac54898e04d7abe245ec12f24d680927351d37a6f31557344adeebd3c2"}, + {"id":"6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681755803,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","9defafc0783f16b2ef9ac02d2e808a103d5d38f12905b7840420634af67d0821"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test1","sig":"17c2489ee28dc32df68ebf87ced6f5f9af0e3f091b31298fece408acba43236bc6c9f50d756f61bcf46a204e043141535e0cd281d541146cbfac259814cd2cbe"}, + {"id":"e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648013,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"powerful quote 👀","sig":"eda9e5565000892dacab78469936d96aad7fb0dc0828630563b8ccb731f08e704eacfefff246b6bb668332e046f5650f2a9e4186c108f07cba07eefc775c6c58"}, + {"id":"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821952,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 7","sig":"e2c35766479d549700f4dbaeae385cfdacee1275ddf24777643ca7d7335c6d1baf7f89ac25d1dc90688bc49f8d2f5e4857d2234035291628a0c0ac760485bab0"}, + {"id":"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821936,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"875339b3e666fb1013d6563552997dcdd02e87b1f290ad25b0d7dd085d2f87dd10a4e6c20dfe3fc109d1809733b93675505341fe149e4399c42371c743fafc73"}, + {"id":"674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648340,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"nested reply","sig":"f0f4fdd2d34e59255137c457b9d42fa9b5357e824f3154340de964616f6daf21e3b03491363b0060f97c5061445d7b2854eed328c5cced751b2b480e5ce8ce9a"}, + {"id":"d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648345,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"another one","sig":"dd732eb380542dd8068449d5617bb8b6c1dfe1495c9facbd46aad3ca9b084122479df30861b8e85e13e7b0c9bf9bce589b7fc062daa086c1ff8a0b1d1ffc7aa7"}, + {"id":"00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648351,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"yet another one","sig":"6332c8a441439487898fd5b423e42dde7d1d7aadfcf9b36defd78d76d1685ba099ac86ae00a1dac6ed5e8a166422d59bbb93bfccb2596dc1c5461efcfcf345b4"}, + {"id":"87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990701,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","","reply"]],"content":"and another one more","sig":"6a385fb259ff40d9b6bd8bb1d5a2e20057ec1c5b5ed2c307fbc40e4a96d4c191b80b6ea3f3f5748c907d4b0e6e64a92210a0be2889af856ad084ec6806f3cbd8"}, + {"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"}, + {"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"} + ] + """.trimIndent() + + val header = + """ + { + "content": "Not too long ago, I tried to paint a picture of what\na [vision for a value-enabled web][vew]\ncould look like. Now, only a couple of months later,\nall this stuff is being built. On nostr, and on lightning. Orange and\npurple, a match made in heaven.\n\nIt goes without saying that I'm beyond delighted. What a time to be alive!\n\n## nostr\n\nHere's the thing that nostr got right, and it's the same thing that\nBitcoin got right: information is easy to spread and hard to stifle.[^fn-stifle]\nInformation can be copied quickly and perfectly, which is, I believe,\nthe underlying reason for its desire to be free.\n\n[^fn-stifle]: That's a [Satoshi quote][stifle], of course: \"Bitcoin's solution is to use a peer-to-peer network to check for double-spending. In a nutshell, the network works like a distributed timestamp server, stamping the first transaction to spend a coin. It takes advantage of the nature of information being easy to spread but hard to stifle.\"\n\n[stifle]: https://satoshi.nakamotoinstitute.org/posts/p2pfoundation/1/\n\nEasy to spread, hard to stifle. That's the base reality of the nature\nof information. As always, the smart thing is to work with nature, not\nagainst it.[^1] That's what's beautiful about the orange coin and\nthe purple ostrich: both manage to work with the peculiarities of\ninformation, not against them. Both realize that information can and should be\ncopied, as it can be perfectly read and easily spread, always. Both understand\nthat resistance to censorship comes from writing to many places, making the cost\nof deletion prohibitive.\n\n> Information does not just want to be free,\n> it longs to be free. Information expands to fill the available\n> storage space. Information is Rumor's younger, stronger cousin;\n> Information is fleeter of foot, has more eyes, knows more, and\n> understands less than Rumor.\n>\n> Eric Hughes, [A Cypherpunk's Manifesto][manifesto]\n\n[manifesto]: https://nakamotoinstitute.org/static/docs/cypherpunk-manifesto.txt\n\nNostr is quickly establishing itself as a base layer for information exchange,\none that is identity-native and value-enabled. It is distinctly different from\nsystems that came before it, just like Bitcoin is distinctly different from\nmonies that came before it.\n\nAs of today, the focus of nostr is mostly on short text notes, the so-called\n\"type 1\" events more commonly known as *tweets*.[^fn-kinds] However, as you should be aware\nby now, nostr is way more than just an alternative to twitter. It is a new\nparadigm. Change the note kind from `1` to `30023` and you don't have an\nalternative to Twitter, but a replacement for Medium, Substack, and all the\nother long-form platforms. I believe that special-purpose clients that focus on\ncertain content types will emerge over time, just like we have seen the\nemergence of special-purpose platforms in the Web 2.0 era. This time, however,\nthe network effects are cumulative, not separate. A new paradigm.\n\nLet me now turn to one such special-purpose client, a nostr-based reading app.\n\n[^fn-kinds]: Refer to the various NIPs to discover the multitude of [event kinds][kinds] defined by the protocol.\n\n[kinds]: https://github.com/nostr-protocol/nips#event-kinds\n[nip23]: https://github.com/nostr-protocol/nips/blob/master/23.md\n\n## Reading\n\nI'm constantly surprised that, even though most people do read a lot\nonline, very few people seem to have a reading workflow or reading\ntools.\n\nWhy that is is anyone's guess, but maybe the added value of such tools\nis not readily apparent. You can just read the stuff right there, on the\nad-ridden, dead-ugly site, right? Why should you sign up for another\nsite, use another app, or bind yourself to another closed platform?\n\nThat's a fair point, but the success of Medium and Substack shows that\nthere is an appetite for clean reading and writing, as well as providing\navenues for authors to get paid for their writing (and a willingness of\nreaders to support said authors, just because).\n\nThe problem is, of course, that all of these platforms are *platforms*,\nwhich is to say, walled gardens that imprison readers and writers alike.\nWorse than that: they are fiat platforms, which means that\npermissionless value-flows are not only absent from their DNA, they are\noutright impossible.[^2]\n\nNostr fixes this.\n\n![Nostriches like to read, or so I've heard](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostrich-reading-a-newspaper.jpg)\n\nThe beauty of nostr is that it is not a platform. It's a protocol,\nwhich means that you don't have to sign up for it---you can create an\nidentity yourself. You don't have to ask for permission; you just *do*,\nwithout having to rely on the benevolence of whatever dictator is in\ncharge of the platform right now.\n\nNostr is *not* a platform, and yet, powerful tools and services can be\nbuilt and monetized on top of it. This is good for users, good for\nservice providers, and good for the network(s) at large. Win-win-win.\n\nSo what am I talking about, exactly? How can nostr improve everyone's\nreading (and writing) experience?\n\nAllow me to paint a (rough) picture of what I have in mind. Nostr\nalready supports private and public bookmarks, so let's start from\nthere.\n\nImagine a special-purpose client that scans all your bookmarks for long-form\ncontent.[^fn-urls] Everything that you marked to be read later is shown in an orderly\nfashion, which is to say searchable, sortable, filterable, and displayed without\ndistractions. Voilà, you have yourself a reading app. That's, in essence, how\nPocket, Readwise, and other reading apps work. But all these apps are walled\ngardens without much interoperability and without direct monetization.\n\n[^fn-urls]: In the nostr world long-form content is simply markdown as defined in [NIP-23][nip23], but it could also be a link to an article or PDF, which in turn could get [converted into markdown][readability] and posted as an event to a special relay.\n\n[readability]: https://github.com/mozilla/readability\n\nBitcoin fixes the direct monetization part.[^fn-v4v] Nostr fixes the interoperability part.\n\n[^fn-v4v]: ...because Bitcoin makes [V4V][busking] practical. (Paywalls are not the way.)\n\nAlright, we got ourselves a boring reading app. Great. Now, imagine that\nusers are able to highlight passages. These highlights, just like\nbookmarks now, could be private or public. When shared publicly,\nsomething interesting emerges: an overlay on existing content, a lens on\nthe written Web. In other words: *swarm highlights*.\n\nImagine a visual overlay of all public highlights, automatically shining\na light on what the swarm of readers found most useful, insightful,\nfunny, etc.\n\n![Swarm Highlights](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/highlights.png)\n\nFurther, imagine the possibility of sharing these highlights as a \"type 1\" event\nwith one click, automatically tagging the highlighter(s)---as well as the\nauthor, of course---so that eventual sat-flows can be split and forwarded\nautomatically.\n\n![Automated value splits](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/sat-flows.png)\n\nVoilà, you have a system that allows for value to flow back to those who\nprovide it, be it authors, editors, curators, or readers that willingly\nslog through the information jungle to share and highlight the best\nstuff (which is a form of curation, of course).\n\nZaps make nostr a defacto address book[^fn-pp] of payment information, which is\nto say lightning addresses, as of now. Thanks to [nostr wallet connect][nwc] (among\nother developments), sending sats ~~will soon be~~ is already as\nfrictionless as leaving a like.\n\n[^fn-pp]: The Yellow Pages are dead, long live [The Purple Pages](http://purplepag.es/)!\n\nValue-for-value and participatory payment flows are something that\ntraditional reading apps desperately lack, be it Pocket, Instapaper,\nReadwise, or the simple reading mode that is part of every browser.\n\nA neat side-effect of a more structured way to share passages of text is\nthat it enables semi-structured discussions around said\npassages---which could be another useful overlay inside\nspecial-purpose clients, providing context and further insights.[^5]\n\nFurther, imagine the option of seamlessly switching from text-on-screen\nto text-to-speech, allowing the user to stream sats if desired, as\nPodcasting 2.0 clients already do.[^3]\n\nImagine user-built curations of the best articles of the week, bundled\nneatly for your reading pleasure, incentivized by a small value split\nthat allows the curator to participate in the flow of sats.\n\nYou get the idea.\n\nI'm sure that the various implementation details will be hashed out,\nbut as I see it, 90% of the stuff is already there. Maybe we'll need\nanother NIP or two, but I don't see a reason why this can't be\nbuilt---and, more importantly: I don't see a reason why it wouldn't\nbe sustainable for everyone involved.\n\nMost puzzle pieces are already there, and the rest of them can probably\nbe implemented by custom event types. From the point of view of nostr,\nmost everything is an event: bookmarks are events, highlights are\nevents, marking something as read is an event, and sharing an excerpt or\na highlight is an event. Public actions are out in the open, private\nactions are encrypted, the data is not in a silo, and everyone wins.\nEspecially the users, those who are at the edge of the network and\nusually lose out on the value generated.\n\nIn this case, the reading case, the users are mostly \"consumers\" of\ncontent. What changes from the producing perspective, the perspective of\nthe writer?\n\n## Writing\n\nBack to the one thing that nostr got right: information is easy to\nspread but hard to stifle. In addition to that, digital information can\nbe copied perfectly, which is why it shouldn't matter where stuff is\npublished in the first place.\n\nAllow me to repeat this point in all caps, for emphasis: **IT SHOULD NOT\nMATTER WHERE INFORMATION IS PUBLISHED**, and, maybe even more\nimportantly, it shouldn't matter if it is published in a hundred\ndifferent places at once.[^fn-torrents]\n\nWhat matters is trust and accuracy, which is to say, digital signatures\nand reputation. To translate this to nostr speak: because every event is\nsigned by default, as long as you trust the person behind the signature,\nit doesn't matter from which relay the information is fetched.\n\nThis is already true (or mostly true) on the regular web. Whether you\nread the internet archive version of an article or the version that is\npublished by an online magazine, the version on the author's website,\nor the version read by some guy that has read more about Bitcoin than\nanyone else you know[^fn-guy]---it's all the same, essentially. What matters\nis the information itself.\n\n[^fn-guy]: There is only one such guy, as we all know, and it's this Guy: nostr:npub1h8nk2346qezka5cpm8jjh3yl5j88pf4ly2ptu7s6uu55wcfqy0wq36rpev\n\nPractically speaking, the source of truth in a hypernostrized world is---you\nguessed it---an event. An event signed by the author, which allows for\nthe information to be wrapped in a tamper-proof manner, which in turn\nallows the information to spread far and wide---without it being\nhosted in one place.\n\nThe first clients that focus on long-form content already exist, and I expect\nmore clients to pop up over time.[^4] As mentioned before, one could easily\nimagine [prism-like value splits][prism] seamlessly integrated into these\nclients, splitting zaps automatically to compensate writers, editors,\nproofreaders, and illustrators in a V4V fashion. Further, one could imagine\nvarious compute-intensive services built into these special-purpose clients,\nsuch as GPT Ghostwriters, or writing aids such as Grammarly and the like. All\nthese services could be seamlessly paid for in sats, without the requirement of\nany sign-ups or the gathering of any user data. That's the beauty of [money\nproper][rediscovery].\n\n![A clean and simple reading and writing interface](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostr-reader-and-writer.png)\n\nPlagiarism is one issue that needs to be dealt with, of course. Humans\nare greedy, and some humans are assholes. Neither bitcoin nor nostr\nfixes this. However, while plagiarism detection is not necessarily\ntrivial, it is also not impossible, especially if most texts are\npublished on nostr first. Nostr-based publishing tools allow for\nOpenTimestamp attestations thanks\nto [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md),\nwhich in turn allows for plagiarism detection based on \"first seen\"\nlookups.\n\nThat's just one way to deal with the problem, of course. In any case,\nI'm confident that we'll figure it out.\n\n## Value\n\nI believe that in the open ~~attention~~ information economy we find\nourselves in, value will mostly derive from effective curation,\ndissemination, and transmission of information, *not* the exclusive\nownership of it.\n\nAlthough it is still early days,\nthe [statistics](https://stats.podcastindex.org/v4v) around Podcasting\n2.0 and [nostr zaps](https://zaplife.lol/) clearly show that (a) people\nare willing to monetarily reward content they care about, and (b) the\nwillingness to send sats *increases* as friction *decreases*.\n\nThe ingenious thing about boostagrams and zaps is that they are direct\nand visible, which is to say, public and interactive. They are neither\nregular transactions nor simple donations---they are something else\nentirely. An unforgable value signal, a special form of gratitude and\nappreciation.\n\nContrast that with a link to Paypal or Patreon: impersonal, slow,\nindirect, and friction-laden. It's the opposite of a super-charged\ninteraction.\n\nWhile today's information jungle increasingly presents itself in the\nform of (short) videos and (long-form) audio, I believe that we will see\na renaissance of the written word, especially if we manage to move away\nfrom an economy built around attention, towards an economy built upon\nvalue and insight.\n\nThe orange future now has a purple hue, and I believe that it will be as\nbright as ever. We just have a lot of building to do.\n\n---\n\n## Further Reading\n\n- [A Vision for a Value-Enabled Web][vew]\n- [The Freedom of Value][busking]\n- [The Rediscovery of Money][prism]\n- [Lightning Prisms][rediscovery]\n\n[vew]: https://dergigi.com/vew\n[prism]: https://dergigi.com/prism\n[rediscovery]: https://dergigi.com/rediscovery\n[busking]: https://dergigi.com/busking\n\n## NIPs and Resources\n\n- [Nostr Resources][nr]\n- [value4value.info](https://value4value.info/)\n- [nips.be](https://nips.be/)\n- [NIP-23: Long-form content](https://github.com/nostr-protocol/nips/blob/master/23.md)\n- [NIP-57: Event-specific zap markers](https://github.com/nostr-protocol/nips/blob/master/57.md)\n- [NIP-47: Nostr Wallet Connect](https://github.com/getAlby/nips/blob/master/47.md)\n- [NIP-03: OpenTimestamps attestations for events](https://github.com/nostr-protocol/nips/blob/master/03.md)\n\nOriginally published on [dergigi.com](https://dergigi.com/reader)\n\n---\n\n[^1]: Paywalls work against this nature, which is why I consider them misguided at best and incredibly retarded at worst.\n\n[^2]: Fiat doesn't work for the [value-enabled web][vew], as fiat rails can never be open and permissionless. Digital fiat is never money. It is---and always will be---[credit][rediscovery].\n\n[^3]: Whether the recipient is a text-to-speech service provider or a human narrator doesn't even matter too much, sats will flow just the same.\n\n[^4]: [BlogStack](https://blogstack.io/) and [Habla](https://habla.news/) being two of them.\n\n[^5]: Use a URI as the discussion base (instead of a highlight), and you got yourself a [Disqus](https://disqus.com/) in purple feathers!\n\n[^fn-torrents]: That's what torrents got right, and [ipfs] for that matter.\n\n[nr]: https://nostr-resources.com\n[nwc]: https://nwc.getalby.com/\n[ipfs]: https://fiatjaf.com/d5031e5b.html\n", + "created_at": 1680614039, + "id": "9517aa60334990596fdea4493c1bde429c55e9cefb84d8d88a6758c4e3ebcabc", + "kind": 30023, + "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + "sig": "c8c8c7ac8c288f6a6e1090e0e5f7eed85daaaba03eeab0a4936535f95ce066c08723bedcfb80a6af4941a9fafb9fe351950b9376d13ff0e81030cd4391840f04", + "tags": [ + ["d","1680612926599"], + ["title","Purple Text, Orange Highlights"], + ["summary","Using nostr as a base layer to create, consume, and monetize long-form content."], + ["published_at","1680613802"], + ["t","nostr"], + ["t","reading"], + ["t","writing"], + ["t","readability"], + ["t","highlights"], + ["t","pocket"], + ["t","instapaper"], + ["t","readwise"], + ["t","disqus"], + ["t","v4v"], + ["t","value4value"], + ["image","https://dergigi.com/assets/images/reader.jpg"], + ["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"] + ] + } + """.trimIndent() + } @Test fun threadOrderTest() = runBlocking { val eventArray = - EventMapper.mapper.readValue>(db) as List + Event.fromJson(header) + JsonMapper.mapper.readValue>(db) as List + Event.fromJson(header) var counter = 0 eventArray.forEach { @@ -138,11 +166,6 @@ class ThreadDualAxisChartAssemblerTest { null, ) - val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob())) - withContext(Dispatchers.Main) { - val user = account.userProfile().flow() - } - val filter = ThreadFeedFilter(account, naddr.toTag()) val calculatedFeed = filter.feed() diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index 2bb1fdda0f..8f832704ec 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -89,14 +89,14 @@ object PushDistributorHandler : PushDistributorActions { override fun saveDistributor(distributor: String) { unifiedPush.saveDistributor(appContext(), distributor) - unifiedPush.registerApp(appContext()) + unifiedPush.register(appContext()) } override fun removeSavedDistributor() { - unifiedPush.safeRemoveDistributor(appContext()) + unifiedPush.removeDistributor(appContext()) } fun forceRemoveDistributor(context: Context) { - unifiedPush.forceRemoveDistributor(context) + UnifiedPush.removeDistributor(context) } } diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 33b3ffdc76..eed9a8c81b 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -34,7 +34,10 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.MessagingReceiver +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage class PushMessageReceiver : MessagingReceiver() { companion object { @@ -48,10 +51,10 @@ class PushMessageReceiver : MessagingReceiver() { override fun onMessage( context: Context, - message: ByteArray, + message: PushMessage, instance: String, ) { - val messageStr = message.decodeToString() + val messageStr = message.content.decodeToString() Log.d(TAG, "New message $messageStr for Instance: $instance") scope.launch { try { @@ -81,10 +84,10 @@ class PushMessageReceiver : MessagingReceiver() { override fun onNewEndpoint( context: Context, - endpoint: String, + endpoint: PushEndpoint, instance: String, ) { - val sanitizedEndpoint = if (endpoint.endsWith("?up=1")) endpoint.dropLast(5) else endpoint + val sanitizedEndpoint = if (endpoint.url.endsWith("?up=1")) endpoint.url.dropLast(5) else endpoint.url if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) { Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint") pushHandler.setEndpoint(sanitizedEndpoint) @@ -102,6 +105,7 @@ class PushMessageReceiver : MessagingReceiver() { override fun onRegistrationFailed( context: Context, + reason: FailedReason, instance: String, ) { Log.d(TAG, "Registration failed for Instance: $instance") diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 0d080d7b03..b6e13bc24f 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index f65b84ef8e..1adc0f6598 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -23,6 +23,9 @@ + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 91b9e0ad57..d115ad178d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -37,17 +37,19 @@ import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket +import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor 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.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector 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.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.ui.tor.TorManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -89,38 +91,40 @@ class Amethyst : Application() { scope = applicationIOScope, ) - // Connects the NostrClient class with okHttp - val factory = - OkHttpWebSocket.BuilderFactory { _, useProxy -> - okHttpClients.getHttpClient(useProxy) - } + val torProxySettingsAnchor = ProxySettingsAnchor() - // Provides a relay pool - val client: NostrClient = NostrClient(factory) + // Connects the NostrClient class with okHttp + val websocketBuilder = + OkHttpWebSocket.Builder { url -> + okHttpClients.getHttpClient(torProxySettingsAnchor.useProxy(url)) + } // Caches all events in Memory val cache: LocalCache = LocalCache + // Organizes cache clearing + val trimmingService = MemoryTrimmingService(cache) + + // Provides a relay pool + val client: NostrClient = NostrClient(websocketBuilder, applicationIOScope) + + // Watches for changes on Tor and Relay List Settings + val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope) + // Verifies and inserts in the cache from all relays, all subscriptions - val cacheClientConnector = CacheClientConnector(client, cache) + val cacheClientConnector = CacheClientConnector(client, cache, applicationIOScope) // Show messages from the Relay and controls their dismissal val notifyCoordinator = NotifyCoordinator(client) // Authenticates with relays. - val authCoordinator = AuthCoordinator(client) + val authCoordinator = AuthCoordinator(client, applicationIOScope) val logger = if (isDebug) RelaySpeedLogger(client) else null - // 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) } @@ -150,7 +154,10 @@ class Amethyst : Application() { } // initializes diskcache on an IO thread. - applicationIOScope.launch { videoCache } + applicationIOScope.launch { + diskCache + videoCache + } // registers to receive events pokeyReceiver.register(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index 766ae8b776..7d3d0719b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -30,12 +30,13 @@ import com.vitorpamplona.amethyst.model.LocalCache import kotlin.time.DurationUnit import kotlin.time.measureTimedValue +@Suppress("SENSELESS_COMPARISON") 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() }}") } + // Amethyst.instance.client + // .allSubscriptions() + // .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.filters.joinToString { it.filter.toJson() }}") } val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024) val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024) @@ -58,7 +59,16 @@ fun debugState(context: Context) { Log.d("STATE DUMP", "Memory Class $memClass MB (largeHeap $isLargeHeap)") } - Log.d("STATE DUMP", "Connected Relays: " + Amethyst.instance.client.connectedRelays()) + Log.d( + "STATE DUMP", + "Connected Relays: " + + Amethyst.instance.client + .relayStatusFlow() + .value.connected.size + "/" + + Amethyst.instance.client + .relayStatusFlow() + .value.available.size, + ) Log.d( "STATE DUMP", @@ -112,7 +122,7 @@ fun debugState(context: Context) { Log.d( "STATE DUMP", "Spam: " + - LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(), + LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentEventIds.size() + " / " + LocalCache.antiSpam.recentAddressables.size(), ) Log.d( @@ -132,21 +142,24 @@ fun debugState(context: Context) { LocalCache.addressables .sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L }) - qttNotes.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) -> - Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ") + qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) -> + Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ") } - qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) -> - Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ") + qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) -> + Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ") } } inline fun logTime( debugMessage: String, + minToReportMs: Int = 1, block: () -> T, ): T = if (isDebug) { val (result, elapsed) = measureTimedValue(block) - Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage") + if (elapsed.inWholeMilliseconds > minToReportMs) { + Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage") + } result } else { block() @@ -154,11 +167,14 @@ inline fun logTime( inline fun logTime( debugMessage: (T) -> String, + minToReportMs: Int = 1, block: () -> T, ): T = if (isDebug) { val (result, elapsed) = measureTimedValue(block) - Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}") + if (elapsed.inWholeMilliseconds > minToReportMs) { + Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}") + } result } else { block() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 5349ffe8ff..62507b79fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -27,33 +27,37 @@ import android.util.Log import androidx.compose.runtime.Immutable import androidx.core.content.edit import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.Event 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.KeyPair -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -82,7 +86,6 @@ private object PrefKeys { const val SAVED_ACCOUNTS = "all_saved_accounts" const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" - const val RELAYS = "relays" const val LOCAL_RELAY_SERVERS = "localRelayServers" const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" @@ -95,10 +98,15 @@ private object PrefKeys { const val LATEST_DM_RELAY_LIST = "latestDMRelayList" const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" + const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList" + const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList" const val LATEST_MUTE_LIST = "latestMuteList" const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList" const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData" const val LATEST_CHANNEL_LIST = "latestChannelList" + const val LATEST_COMMUNITY_LIST = "latestCommunityList" + const val LATEST_HASHTAG_LIST = "latestHashtagList" + const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" @@ -120,8 +128,8 @@ object LocalPreferences { private const val COMMA = "," private var currentAccount: String? = null - private var savedAccounts: MutableStateFlow?> = MutableStateFlow(null) - private var cachedAccounts: MutableMap = mutableMapOf() + private val savedAccounts: MutableStateFlow?> = MutableStateFlow(null) + private val cachedAccounts: MutableMap = mutableMapOf() suspend fun currentAccount(): String? { if (currentAccount == null) { @@ -155,7 +163,7 @@ object LocalPreferences { with(encryptedPreferences()) { val newSystemOfAccounts = getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { - EventMapper.mapper.readValue>(it) + JsonMapper.mapper.readValue>(it) } if (!newSystemOfAccounts.isNullOrEmpty()) { @@ -175,7 +183,7 @@ object LocalPreferences { savedAccounts.emit(migrated) - edit { putString(PrefKeys.ALL_ACCOUNT_INFO, EventMapper.mapper.writeValueAsString(savedAccounts.value)) } + edit { putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.mapper.writeValueAsString(savedAccounts.value)) } } } } @@ -195,7 +203,7 @@ object LocalPreferences { .edit { putString( PrefKeys.ALL_ACCOUNT_INFO, - EventMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }), + JsonMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }), ) } } @@ -249,7 +257,7 @@ object LocalPreferences { if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) } else { - return Amethyst.instance.encryptedStorage(npub) + Amethyst.instance.encryptedStorage(npub) } } @@ -262,7 +270,7 @@ object LocalPreferences { * deleted */ @SuppressLint("ApplySharedPref") - suspend fun updatePrefsForLogout(accountInfo: AccountInfo) { + suspend fun deleteAccount(accountInfo: AccountInfo) { Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}") withContext(Dispatchers.IO) { encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } @@ -277,7 +285,7 @@ object LocalPreferences { } } - suspend fun updatePrefsForLogin(accountSettings: AccountSettings) { + suspend fun setDefaultAccount(accountSettings: AccountSettings) { setCurrentAccount(accountSettings) saveToEncryptedStorage(accountSettings) } @@ -299,11 +307,10 @@ object LocalPreferences { settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } } settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString(PrefKeys.RELAYS, EventMapper.mapper.writeValueAsString(settings.localRelays)) putString( PrefKeys.DEFAULT_FILE_SERVER, - EventMapper.mapper.writeValueAsString(settings.defaultFileServer), + JsonMapper.mapper.writeValueAsString(settings.defaultFileServer), ) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) @@ -317,12 +324,12 @@ object LocalPreferences { ) putString( PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, - EventMapper.mapper.writeValueAsString(settings.zapPaymentRequest), + JsonMapper.mapper.writeValueAsString(settings.zapPaymentRequest.value?.denormalize()), ) if (settings.backupContactList != null) { putString( PrefKeys.LATEST_CONTACT_LIST, - EventMapper.mapper.writeValueAsString(settings.backupContactList), + JsonMapper.mapper.writeValueAsString(settings.backupContactList), ) } else { remove(PrefKeys.LATEST_CONTACT_LIST) @@ -331,7 +338,7 @@ object LocalPreferences { if (settings.backupUserMetadata != null) { putString( PrefKeys.LATEST_USER_METADATA, - EventMapper.mapper.writeValueAsString(settings.backupUserMetadata), + JsonMapper.mapper.writeValueAsString(settings.backupUserMetadata), ) } else { remove(PrefKeys.LATEST_USER_METADATA) @@ -340,7 +347,7 @@ object LocalPreferences { if (settings.backupDMRelayList != null) { putString( PrefKeys.LATEST_DM_RELAY_LIST, - EventMapper.mapper.writeValueAsString(settings.backupDMRelayList), + JsonMapper.mapper.writeValueAsString(settings.backupDMRelayList), ) } else { remove(PrefKeys.LATEST_DM_RELAY_LIST) @@ -349,7 +356,7 @@ object LocalPreferences { if (settings.backupNIP65RelayList != null) { putString( PrefKeys.LATEST_NIP65_RELAY_LIST, - EventMapper.mapper.writeValueAsString(settings.backupNIP65RelayList), + JsonMapper.mapper.writeValueAsString(settings.backupNIP65RelayList), ) } else { remove(PrefKeys.LATEST_NIP65_RELAY_LIST) @@ -358,14 +365,32 @@ object LocalPreferences { if (settings.backupSearchRelayList != null) { putString( PrefKeys.LATEST_SEARCH_RELAY_LIST, - EventMapper.mapper.writeValueAsString(settings.backupSearchRelayList), + JsonMapper.mapper.writeValueAsString(settings.backupSearchRelayList), ) } else { remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) } - if (settings.localRelayServers.isNotEmpty()) { - putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers) + if (settings.backupBlockedRelayList != null) { + putString( + PrefKeys.LATEST_BLOCKED_RELAY_LIST, + JsonMapper.mapper.writeValueAsString(settings.backupBlockedRelayList), + ) + } else { + remove(PrefKeys.LATEST_BLOCKED_RELAY_LIST) + } + + if (settings.backupTrustedRelayList != null) { + putString( + PrefKeys.LATEST_TRUSTED_RELAY_LIST, + JsonMapper.mapper.writeValueAsString(settings.backupTrustedRelayList), + ) + } else { + remove(PrefKeys.LATEST_TRUSTED_RELAY_LIST) + } + + if (settings.localRelayServers.value.isNotEmpty()) { + putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value) } else { remove(PrefKeys.LOCAL_RELAY_SERVERS) } @@ -373,7 +398,7 @@ object LocalPreferences { if (settings.backupMuteList != null) { putString( PrefKeys.LATEST_MUTE_LIST, - EventMapper.mapper.writeValueAsString(settings.backupMuteList), + JsonMapper.mapper.writeValueAsString(settings.backupMuteList), ) } else { remove(PrefKeys.LATEST_MUTE_LIST) @@ -382,7 +407,7 @@ object LocalPreferences { if (settings.backupPrivateHomeRelayList != null) { putString( PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, - EventMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList), + JsonMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList), ) } else { remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) @@ -391,7 +416,7 @@ object LocalPreferences { if (settings.backupAppSpecificData != null) { putString( PrefKeys.LATEST_APP_SPECIFIC_DATA, - EventMapper.mapper.writeValueAsString(settings.backupAppSpecificData), + JsonMapper.mapper.writeValueAsString(settings.backupAppSpecificData), ) } else { remove(PrefKeys.LATEST_APP_SPECIFIC_DATA) @@ -400,16 +425,43 @@ object LocalPreferences { if (settings.backupChannelList != null) { putString( PrefKeys.LATEST_CHANNEL_LIST, - EventMapper.mapper.writeValueAsString(settings.backupChannelList), + JsonMapper.mapper.writeValueAsString(settings.backupChannelList), ) } else { remove(PrefKeys.LATEST_CHANNEL_LIST) } + if (settings.backupCommunityList != null) { + putString( + PrefKeys.LATEST_COMMUNITY_LIST, + JsonMapper.mapper.writeValueAsString(settings.backupCommunityList), + ) + } else { + remove(PrefKeys.LATEST_COMMUNITY_LIST) + } + + if (settings.backupHashtagList != null) { + putString( + PrefKeys.LATEST_HASHTAG_LIST, + JsonMapper.mapper.writeValueAsString(settings.backupHashtagList), + ) + } else { + remove(PrefKeys.LATEST_HASHTAG_LIST) + } + + if (settings.backupGeohashList != null) { + putString( + PrefKeys.LATEST_HASHTAG_LIST, + JsonMapper.mapper.writeValueAsString(settings.backupGeohashList), + ) + } else { + remove(PrefKeys.LATEST_HASHTAG_LIST) + } + if (settings.backupEphemeralChatList != null) { putString( PrefKeys.LATEST_EPHEMERAL_LIST, - EventMapper.mapper.writeValueAsString(settings.backupEphemeralChatList), + JsonMapper.mapper.writeValueAsString(settings.backupEphemeralChatList), ) } else { remove(PrefKeys.LATEST_EPHEMERAL_LIST) @@ -423,7 +475,7 @@ object LocalPreferences { remove(PrefKeys.USE_PROXY) remove(PrefKeys.PROXY_PORT) - putString(PrefKeys.TOR_SETTINGS, EventMapper.mapper.writeValueAsString(settings.torSettings.toSettings())) + putString(PrefKeys.TOR_SETTINGS, JsonMapper.mapper.writeValueAsString(settings.torSettings.toSettings())) val regularMap = settings.lastReadPerRoute.value.mapValues { @@ -432,13 +484,13 @@ object LocalPreferences { putString( PrefKeys.LAST_READ_PER_ROUTE, - EventMapper.mapper.writeValueAsString(regularMap), + JsonMapper.mapper.writeValueAsString(regularMap), ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putString( PrefKeys.PENDING_ATTESTATIONS, - EventMapper.mapper.writeValueAsString(settings.pendingAttestations.value), + JsonMapper.mapper.writeValueAsString(settings.pendingAttestations.value), ) } } @@ -453,9 +505,8 @@ object LocalPreferences { prefs: SharedPreferences = encryptedPreferences(), ) { Log.d("LocalPreferences", "Saving to shared settings") - with(prefs.edit()) { - putString(PrefKeys.SHARED_SETTINGS, EventMapper.mapper.writeValueAsString(sharedSettings)) - apply() + prefs.edit { + putString(PrefKeys.SHARED_SETTINGS, JsonMapper.mapper.writeValueAsString(sharedSettings)) } } @@ -463,7 +514,7 @@ object LocalPreferences { Log.d("LocalPreferences", "Load shared settings") with(prefs) { return try { - getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { EventMapper.mapper.readValue(it) } + getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { JsonMapper.mapper.readValue(it) } } catch (e: Throwable) { if (e is CancellationException) throw e Log.w( @@ -471,7 +522,6 @@ object LocalPreferences { "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e, ) - e.printStackTrace() null } } @@ -514,7 +564,7 @@ object LocalPreferences { ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null val defaultHomeFollowList = - getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS + getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val defaultNotificationFollowList = @@ -522,8 +572,6 @@ object LocalPreferences { val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() - val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] @@ -535,11 +583,16 @@ object LocalPreferences { val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) + val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) + val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_RELAY_LIST) val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) - val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) val latestChannelList = parseEventOrNull(PrefKeys.LATEST_CHANNEL_LIST) + val latestCommunityList = parseEventOrNull(PrefKeys.LATEST_COMMUNITY_LIST) + val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) + val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) + val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -559,14 +612,13 @@ object LocalPreferences { keyPair = keyPair, transientAccount = false, externalSignerPackageName = externalSignerPackageName, - localRelays = localRelays, - localRelayServers = localRelayServers, + localRelayServers = MutableStateFlow(localRelayServers), defaultFileServer = defaultFileServer, defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer, + zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer?.normalize()), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, @@ -575,10 +627,15 @@ object LocalPreferences { backupNIP65RelayList = latestNip65RelayList, backupDMRelayList = latestDmRelayList, backupSearchRelayList = latestSearchRelayList, + backupBlockedRelayList = latestBlockedRelayList, + backupTrustedRelayList = latestTrustedRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList, backupMuteList = latestMuteList, backupAppSpecificData = latestAppSpecificData, backupChannelList = latestChannelList, + backupCommunityList = latestCommunityList, + backupHashtagList = latestHashtagList, + backupGeohashList = latestGeohashList, backupEphemeralChatList = latestEphemeralList, torSettings = TorSettingsFlow.build(torSettings), lastReadPerRoute = MutableStateFlow(lastReadPerRoute), @@ -600,12 +657,11 @@ object LocalPreferences { if (T::class.java.isInstance(Event::class.java)) { Event.fromJson(value) as T? } else { - EventMapper.mapper.readValue(value) + JsonMapper.mapper.readValue(value) } } catch (e: Throwable) { if (e is CancellationException) throw e Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) - e.printStackTrace() null } } @@ -620,7 +676,6 @@ object LocalPreferences { } catch (e: Throwable) { if (e is CancellationException) throw e Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) - e.printStackTrace() null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt deleted file mode 100644 index 7b48e4c071..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst - -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume - -/** - * Launches an async coroutine for each item, runs the - * function and waits for everybody to finsih - */ -suspend fun launchAndWaitAll( - items: List, - asyncFunc: suspend (T) -> Unit, -) { - coroutineScope { - val jobs = - items.map { next -> - async { - asyncFunc(next) - } - } - - // runs in parallel to avoid overcrowding Amber. - withTimeoutOrNull(15000) { - jobs.joinAll() - } - } -} - -/** - * Runs the function and waits for 10 seconds for any result. - */ -suspend inline fun tryAndWait( - timeoutMillis: Long = 10000, - crossinline asyncFunc: (Continuation) -> Unit, -): T? = - withTimeoutOrNull(timeoutMillis) { - suspendCancellableCoroutine { continuation -> - asyncFunc(continuation) - } - } - -/** - * Runs an async coroutine for each one of the items, - * runs the request for that item, - * and gathers all the results in the output map. - */ -suspend fun collectSuccessfulOperations( - items: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableList = mutableListOf(), - onReady: suspend (List) -> Unit, -) { - if (items.isEmpty()) { - onReady(output) - return - } - - launchAndWaitAll(items) { - val result = - tryAndWait { continuation -> - runRequestFor(it) { result: K -> continuation.resume(result) } - } - - if (result != null) { - output.add(result) - } - } - - onReady(output) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt deleted file mode 100644 index bc464ebdc1..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst - -import android.util.Log -import androidx.compose.runtime.Stable -import coil3.annotation.DelicateCoilApi -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.ammolite.relays.NostrClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -@Stable -class ServiceManager( - val client: NostrClient, - val scope: CoroutineScope, -) { - // to not open amber in a loop trying to use auth relays and registering for notifications - private var isStarted: Boolean = false - - private var account: Account? = null - - private var collectorJob: Job? = null - - private fun start(account: Account) { - this.account = account - start() - } - - @OptIn(DelicateCoilApi::class) - private fun start() { - Log.d("ServiceManager", "-- May Start (hasStarted: $isStarted) for account $account") - if (isStarted && account != null) { - Log.d("ServiceManager", "---- Restarting innactive relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}") - client.reconnect() - return - } - Log.d("ServiceManager", "---- Starting Relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}") - - val myAccount = account - - if (myAccount != null) { - val relaySet = myAccount.connectToRelaysWithProxy.value - client.reconnect(relaySet) - - collectorJob?.cancel() - collectorJob = null - collectorJob = - scope.launch { - myAccount.connectToRelaysWithProxy.collectLatest { - delay(500) - if (isStarted) { - client.reconnect(it, onlyIfChanged = true) - } - } - } - - isStarted = true - } - } - - private fun pause() { - Log.d("ServiceManager", "-- Pausing Relay Services") - - collectorJob?.cancel() - collectorJob = null - - client.reconnect(null) - isStarted = false - } - - fun cleanObservers() { - LocalCache.cleanObservers() - } - - // This method keeps the pause/start in a Syncronized block to - // avoid concurrent pauses and starts. - @Synchronized - fun forceRestart( - account: Account? = null, - start: Boolean = true, - pause: Boolean = true, - ) { - Log.d("ServiceManager", "-- Force Restart (start:$start) (pause:$pause) for $account") - if (pause) { - pause() - } - - if (start) { - if (account != null) { - start(account) - } else { - start() - } - } - } - - fun setAccountAndRestart(account: Account) { - forceRestart(account, true, true) - } - - fun forceRestart() { - forceRestart(null, true, true) - } - - fun justStartIfItHasAccount() { - if (account != null) { - forceRestart(null, true, false) - } - } - - fun pauseForGood() { - forceRestart(null, false, true) - } - - fun pauseAndLogOff() { - account = null - forceRestart(null, false, true) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f153af0263..f0c81c0462 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -21,40 +21,77 @@ package com.vitorpamplona.amethyst.model import android.util.Log -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.fasterxml.jackson.module.kotlin.readValue -import com.fonfon.kgeohash.GeoHash import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListDecryptionCache import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay +import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState +import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.nip18Reposts.RepostAction +import com.vitorpamplona.amethyst.model.nip25Reactions.ReactionAction +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState -import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState +import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState +import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState +import com.vitorpamplona.amethyst.model.nip56Reports.ReportAction +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState +import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState +import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState +import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.model.privacyOptions.PrivacyState +import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState +import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState +import com.vitorpamplona.amethyst.model.serverList.MergedServerListState +import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState +import com.vitorpamplona.amethyst.model.torState.TorRelayState 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.ots.OkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.service.uploads.FileHeader -import com.vitorpamplona.amethyst.tryAndWait -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -75,33 +112,32 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.mimeType +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent +import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.reply import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -110,10 +146,8 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes @@ -121,53 +155,42 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.Entity 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.NNote 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.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftBuilder import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip68Picture.pictureIMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId -import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -180,975 +203,219 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.combineTransform -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.czeal.rfc3986.URIReference import java.math.BigDecimal -import java.util.Base64 import java.util.Locale -import kotlin.coroutines.cancellation.CancellationException -import kotlin.coroutines.resume @OptIn(DelicateCoroutinesApi::class) @Stable class Account( val settings: AccountSettings = AccountSettings(KeyPair()), - val signer: NostrSigner = settings.createSigner(), + val signer: NostrSigner, + geolocationFlow: StateFlow, + val cache: LocalCache, + val client: NostrClient, val scope: CoroutineScope, ) { - companion object { - const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" - } - - var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) - - @Immutable - class LiveFollowList( - val authors: Set = emptySet(), - val authorsPlusMe: Set, - val hashtags: Set = emptySet(), - val geotags: Set = emptySet(), - val addresses: Set = emptySet(), - ) { - val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } - val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } - } - - class FeedsBaseFlows( - val listName: String, - val peopleList: StateFlow = MutableStateFlow(NoteState(Note(" "))), - val kind3: StateFlow = MutableStateFlow(null), - val location: StateFlow = MutableStateFlow(null), - ) - - val connectToRelaysFlow = - combineTransform( - getNIP65RelayListFlow(), - getDMRelayListFlow(), - getSearchRelayListFlow(), - getPrivateOutboxRelayListFlow(), - userProfile().flow().relays.stateFlow, - ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile -> - checkNotInMainThread() - emit( - normalizeAndCombineRelayListsWithFallbacks( - kind3RelayList = kind3Relays(), - newDMRelayEvent = dmRelayList.note.event as? ChatMessageRelayListEvent, - searchRelayEvent = searchRelayList.note.event as? SearchRelayListEvent, - privateOutboxRelayEvent = privateOutBox.note.event as? PrivateOutboxRelayListEvent, - nip65RelayEvent = nip65RelayList.note.event as? AdvertisedRelayListEvent, - ).toTypedArray(), - ) - } - - private fun normalizeAndCombineRelayListsWithFallbacks( - kind3RelayList: Array? = null, - newDMRelayEvent: ChatMessageRelayListEvent? = null, - searchRelayEvent: SearchRelayListEvent? = null, - privateOutboxRelayEvent: PrivateOutboxRelayListEvent? = null, - nip65RelayEvent: AdvertisedRelayListEvent? = null, - localRelayList: Set? = null, - ) = normalizeAndCombineRelayLists( - baseRelaySet = kind3RelayList ?: convertLocalRelays(), - newDMRelayEvent = newDMRelayEvent ?: settings.backupDMRelayList, - searchRelayEvent = searchRelayEvent ?: settings.backupSearchRelayList, - privateOutboxRelayEvent = privateOutboxRelayEvent ?: settings.backupPrivateHomeRelayList, - nip65RelayEvent = nip65RelayEvent ?: settings.backupNIP65RelayList, - localRelayList = localRelayList ?: settings.localRelayServers, - ) - - private fun normalizeAndCombineRelayLists( - baseRelaySet: Array, - newDMRelayEvent: ChatMessageRelayListEvent?, - searchRelayEvent: SearchRelayListEvent?, - privateOutboxRelayEvent: PrivateOutboxRelayListEvent?, - nip65RelayEvent: AdvertisedRelayListEvent?, - localRelayList: Set, - ): List { - val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val searchRelaySet = (searchRelayEvent?.relays() ?: DefaultSearchRelayList).map { RelayUrlFormatter.normalize(it) }.toSet() - val nip65RelaySet = - nip65RelayEvent?.relays()?.map { - AdvertisedRelayListEvent.AdvertisedRelayInfo( - RelayUrlFormatter.normalize(it.relayUrl), - it.type, - ) - } - val privateOutboxRelaySet = privateOutboxRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val localRelaySet = localRelayList.map { RelayUrlFormatter.normalize(it) }.toSet() - - return combineRelayLists( - baseRelaySet = baseRelaySet, - newDMRelaySet = newDMRelaySet, - searchRelaySet = searchRelaySet, - privateOutboxRelaySet = privateOutboxRelaySet, - nip65RelaySet = nip65RelaySet, - localRelaySet = localRelaySet, - ) - } - - private fun combineRelayLists( - baseRelaySet: Array, - newDMRelaySet: Set, - searchRelaySet: Set, - privateOutboxRelaySet: Set, - nip65RelaySet: List?, - localRelaySet: Set, - ): List { - // ------ - // DMs - // ------ - var mappedRelaySet = - baseRelaySet.map { - if (newDMRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS) - } else { - it - } - } - - newDMRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // ------ - // SEARCH - // ------ - - mappedRelaySet = - mappedRelaySet.map { - if (searchRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH) - } else { - it - } - } - - searchRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - false, - setOf( - FeedType.SEARCH, - ), - ) - } - } - - // -------------- - // PRIVATE OUTBOX - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (privateOutboxRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - privateOutboxRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // Local Storage - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (localRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - localRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // NIP-65 Public Inbox/Outbox - // -------------- - - mappedRelaySet = - mappedRelaySet.map { relay -> - val nip65setup = nip65RelaySet?.firstOrNull { relay.url == it.relayUrl } - if (nip65setup != null) { - val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE - - RelaySetupInfo( - relay.url, - true, - relay.write || write, - relay.feedTypes + - setOf( - FeedType.FOLLOWS, - FeedType.GLOBAL, - FeedType.PUBLIC_CHATS, - ), - ) - } else { - relay - } - } - - nip65RelaySet?.forEach { newNip65Setup -> - if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) { - val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE - - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newNip65Setup.relayUrl, - true, - write, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - ), - ) - } - } - return mappedRelaySet - } - - val connectToRelays = - connectToRelaysFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeAndCombineRelayListsWithFallbacks( - kind3Relays(), - getDMRelayList(), - getSearchRelayList(), - getPrivateOutboxRelayList(), - getNIP65RelayList(), - ).toTypedArray(), - ) - - val connectToRelaysWithProxy = - combineTransform( - connectToRelays, - settings.torSettings.torType, - settings.torSettings.onionRelaysViaTor, - settings.torSettings.trustedRelaysViaTor, - ) { relays, torType, useTorForOnionRelays, useTorForTrustedRelays -> - emit( - relays - .map { - RelaySetupInfoToConnect( - it.url, - torType != TorType.OFF && checkLocalHostOnionAndThen(it.url, useTorForOnionRelays, useTorForTrustedRelays), - it.read, - it.write, - it.feedTypes, - ) - }.toTypedArray(), - ) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeAndCombineRelayListsWithFallbacks( - kind3Relays(), - getDMRelayList(), - getSearchRelayList(), - getPrivateOutboxRelayList(), - getNIP65RelayList(), - ).map { - RelaySetupInfoToConnect( - it.url, - settings.torSettings.torType.value != TorType.OFF && - checkLocalHostOnionAndThen( - it.url, - settings.torSettings.onionRelaysViaTor.value, - settings.torSettings.trustedRelaysViaTor.value, - ), - it.read, - it.write, - it.feedTypes, - ) - }.toTypedArray(), - ) - - fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowList { - // makes sure the output include only valid p tags - val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() - - return LiveFollowList( - authors = verifiedFollowingUsers, - authorsPlusMe = verifiedFollowingUsers + signer.pubKey, - hashtags = - latestContactList - ?.unverifiedFollowTagSet() - ?.map { it.lowercase() } - ?.toSet() ?: emptySet(), - geotags = - latestContactList - ?.geohashes() - ?.toSet() ?: emptySet(), - addresses = - latestContactList - ?.verifiedFollowAddressSet() - ?.toSet() ?: emptySet(), - ) - } - - fun normalizeDMRelayListWithBackup(note: Note): Set { - val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList - return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedDmRelaySet = - getDMRelayListFlow() - .map { normalizeDMRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeDMRelayListWithBackup(getDMRelayListNote()), - ) - - fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { - val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList - return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedPrivateOutBoxRelaySet = - getPrivateOutboxRelayListFlow() - .map { normalizePrivateOutboxRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()), - ) - - fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set { - val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList - return event?.writeRelays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedNIP65WriteRelayList = - getNIP65RelayListFlow() - .map { normalizeNIP65WriteRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeNIP65WriteRelayListWithBackup(getNIP65RelayListNote()), - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3FollowsFlow: Flow = - userProfile().flow().follows.stateFlow.transformLatest { - emit(buildFollowLists(it.user.latestContactList)) - } - - val liveKind3Follows = - liveKind3FollowsFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), - ) - - fun loadFlowsFor(listName: String): FeedsBaseFlows = - when (listName) { - GLOBAL_FOLLOWS -> FeedsBaseFlows(listName) - KIND3_FOLLOWS -> FeedsBaseFlows(listName, kind3 = liveKind3Follows) - AROUND_ME -> - FeedsBaseFlows( - listName, - location = Amethyst.instance.locationManager.geohashStateFlow, - ) - else -> { - val note = LocalCache.checkGetOrCreateAddressableNote(listName) - if (note != null) { - FeedsBaseFlows( - listName, - peopleList = - note - .flow() - .metadata.stateFlow, - ) - } else { - FeedsBaseFlows(listName) - } - } - } - - fun compute50kmLine(geoHash: GeoHash): List { - val hashes = mutableListOf() - - hashes.add(geoHash.toString()) - - var currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.westernNeighbour - hashes.add(currentGeoHash.toString()) - } - - currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.easternNeighbour - hashes.add(currentGeoHash.toString()) - } - - return hashes - } - - fun compute50kmRange(geoHash: GeoHash): List { - val hashes = mutableListOf() - - hashes.addAll(compute50kmLine(geoHash)) - - var currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.northernNeighbour - hashes.addAll(compute50kmLine(currentGeoHash)) - } - - currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.southernNeighbour - hashes.addAll(compute50kmLine(currentGeoHash)) - } - - return hashes - } - - suspend fun mapIntoFollowLists( - listName: String, - kind3: LiveFollowList?, - noteState: NoteState, - location: LocationState.LocationResult?, - ): LiveFollowList? = - if (listName == GLOBAL_FOLLOWS) { - null - } else if (listName == KIND3_FOLLOWS) { - kind3 - } else if (listName == AROUND_ME) { - val geohashResult = location ?: Amethyst.instance.locationManager.geohashStateFlow.value - if (geohashResult is LocationState.LocationResult.Success) { - // 2 neighbors deep = 25x25km - LiveFollowList( - authorsPlusMe = setOf(signer.pubKey), - geotags = compute50kmRange(geohashResult.geoHash).toSet(), - ) - } else { - LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } - } else { - val noteEvent = noteState.note.event - if (noteEvent is GeneralListEvent) { - waitToDecrypt(noteEvent) ?: LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } else if (noteEvent is FollowListEvent) { - LiveFollowList(authors = noteEvent.pubKeys().toSet(), authorsPlusMe = setOf(signer.pubKey) + noteEvent.pubKeys()) - } else { - LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun combinePeopleListFlows(peopleListFollowsSource: Flow): Flow = - peopleListFollowsSource - .transformLatest { listName -> - val followList = loadFlowsFor(listName) - emitAll( - combine(followList.kind3, followList.peopleList, followList.location) { kind3, peopleList, location -> - mapIntoFollowLists(followList.listName, kind3, peopleList, location) - }, - ) - } - - val liveHomeFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultHomeFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultHomeFollowList.value) - }, - ) - } - - val liveServerList: StateFlow> by lazy { - combine(getFileServersListFlow(), getBlossomServersListFlow()) { nip96, blossom -> - mergeServerList(nip96.note.event as? FileServersEvent, blossom.note.event as? BlossomServersEvent) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - mergeServerList(getFileServersList(), getBlossomServersList()) - }, - ) - } - - suspend fun loadAndCombineFlows(listName: String): LiveFollowList? { - val flows = loadFlowsFor(listName) - return mapIntoFollowLists( - flows.listName, - flows.kind3.value, - flows.peopleList.value, - flows.location.value, - ) - } - - /** - * filter onion and local host from write relays - * for each user pubkey, a list of valid relays. - */ - private fun assembleAuthorsPerWriteRelay( - userList: Map>, - hasOnionConnection: Boolean = false, - ): Map> { - checkNotInMainThread() - - val authorsPerRelayUrl = mutableMapOf>() - val relayUrlsPerAuthor = mutableMapOf>() - - userList.forEach { userWriteRelayListPair -> - userWriteRelayListPair.value.forEach { relayUrl -> - if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { - RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> - val userSet = authorsPerRelayUrl[normRelayUrl] - if (userSet != null) { - userSet.add(userWriteRelayListPair.key) - } else { - authorsPerRelayUrl[normRelayUrl] = mutableSetOf(userWriteRelayListPair.key) - } - - val relaySet = authorsPerRelayUrl[userWriteRelayListPair.key] - if (relaySet != null) { - relaySet.add(normRelayUrl) - } else { - relayUrlsPerAuthor[userWriteRelayListPair.key] = mutableSetOf(normRelayUrl) - } - } - } - } - } - - // for each relay, authors that only use this relay go first. - // then keeps order by pubkey asc - val comparator = compareByDescending { relayUrlsPerAuthor[it]?.size ?: 0 }.thenBy { it } - - return authorsPerRelayUrl.mapValues { - it.value.sortedWith(comparator) - } - } - - fun authorsPerRelay( - followsNIP65RelayLists: List, - defaultRelayList: List, - torType: TorType, - ): Map> = authorsPerRelay(followsNIP65RelayLists, defaultRelayList, torType != TorType.OFF) - - fun authorsPerRelay( - followsNIP65RelayLists: List, - defaultRelayList: List, - acceptOnion: Boolean, - ): Map> { - checkNotInMainThread() - - val defaultSet = defaultRelayList.toSet() - - return assembleAuthorsPerWriteRelay( - followsNIP65RelayLists - .mapNotNull - { - val author = (it as? AddressableNote)?.address?.pubKeyHex - val event = (it.event as? AdvertisedRelayListEvent) - - if (event != null) { - val authorWriteRelays = - event.writeRelays().map { - RelayUrlFormatter.normalize(it) - } - - val commonRelaysToMe = authorWriteRelays.filter { it in defaultSet } - if (commonRelaysToMe.isNotEmpty()) { - event.pubKey to commonRelaysToMe - } else { - event.pubKey to defaultRelayList - } - } else { - if (author != null) { - author to defaultRelayList - } else { - Log.e("Account", "This author should NEVER be null. Note: ${it.idHex}") - null - } - } - }.toMap(), - hasOnionConnection = acceptOnion, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveHomeFollowListAdvertizedRelayListFlow: Flow?> = - liveHomeFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveHomeListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torStatus -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - torStatus, - ), - ) - } else { - emit(null) - } - } - } - - val liveHomeListAuthorsPerRelay: StateFlow>?> by lazy { - liveHomeListAuthorsPerRelayFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - val liveNotificationFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultNotificationFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultNotificationFollowList.value) - }, - ) - } - - val liveStoriesFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultStoriesFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultStoriesFollowList.value) - }, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveStoriesFollowListAdvertizedRelayListFlow: Flow?> = - liveStoriesFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveStoriesListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - torState, - ), - ) - } else { - emit(null) - } - } - } - - val liveStoriesListAuthorsPerRelay: StateFlow>?> by lazy { - liveStoriesListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveStoriesFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - val liveDiscoveryFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultDiscoveryFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultDiscoveryFollowList.value) - }, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveDiscoveryFollowListAdvertizedRelayListFlow: Flow?> = - liveDiscoveryFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveDiscoveryListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.read }.map { it.url }, - torState, - ), - ) - } else { - emit(null) - } - } - } - - val liveDiscoveryListAuthorsPerRelay: StateFlow>?> by lazy { - liveDiscoveryListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveDiscoveryFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - private fun decryptLiveFollows( - listEvent: GeneralListEvent, - onReady: (LiveFollowList) -> Unit, - ) { - listEvent.privateTags(signer) { privateTagList -> - val users = (listEvent.taggedUserIds() + listEvent.filterUsers(privateTagList)).toSet() - onReady( - LiveFollowList( - authors = users, - authorsPlusMe = users + userProfile().pubkeyHex, - hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), - geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(), - addresses = - (listEvent.taggedATags() + listEvent.filterATags(privateTagList)) - .map { it.toTag() } - .toSet(), - ), - ) - } - } - - fun decryptPeopleList( - event: GeneralListEvent, - onReady: (Array>) -> Unit, - ) = event.privateTags(signer, onReady) - - suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? = - tryAndWait { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - - @Immutable - class LiveHiddenUsers( - val hiddenUsers: Set, - val spammers: Set, - val hiddenWords: Set, - val showSensitiveContent: Boolean?, - ) { - // speeds up isHidden calculations - val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() } - val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() } - val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) } - } - - suspend fun decryptPeopleList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords { - if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - - return tryAndWait { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() - } - - suspend fun decryptMuteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords { - if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - - return tryAndWait { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() - } - - suspend fun assembleLiveHiddenUsers( - blockList: Note, - muteList: Note, - transientHiddenUsers: Set, - showSensitiveContent: Boolean?, - ): LiveHiddenUsers { - val resultBlockList = decryptPeopleList(blockList.event as? PeopleListEvent) - val resultMuteList = decryptMuteList(muteList.event as? MuteListEvent) - - return LiveHiddenUsers( - hiddenUsers = resultBlockList.users + resultMuteList.users, - hiddenWords = resultBlockList.words + resultMuteList.words, - spammers = transientHiddenUsers, - showSensitiveContent = showSensitiveContent, - ) - } - - val flowHiddenUsers: StateFlow by lazy { - combineTransform( - getBlockListNote().flow().metadata.stateFlow, - getMuteListNote().flow().metadata.stateFlow, - transientHiddenUsers, - settings.syncedSettings.security.showSensitiveContent, - ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> - checkNotInMainThread() - emit(assembleLiveHiddenUsers(blockList.note, muteList.note, transientHiddenUsers, showSensitiveContent)) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - assembleLiveHiddenUsers( - getBlockListNote(), - getMuteListNote(), - transientHiddenUsers.value, - settings.syncedSettings.security.showSensitiveContent.value, - ) - }, - ) - } - - @OptIn(FlowPreview::class) - val decryptBookmarks: Flow by lazy { - userProfile() - .flow() - .bookmarks.stateFlow - .map { userState -> - if (userState.user.latestBookmarkList == null) { - null - } else { - val result = - tryAndWait { continuation -> - userState.user.latestBookmarkList?.privateTags(signer) { - continuation.resume(userState.user.latestBookmarkList) - } - } - - result - } - }.debounce(1000) - .flowOn(Dispatchers.Default) - } - - val emoji = EmojiPackState(signer, LocalCache, scope) - val ephemeralChatList = EphemeralChatListState(signer, LocalCache, scope) - val publicChatList = PublicChatListState(signer, LocalCache, scope) - private var userProfileCache: User? = null - fun userProfile(): User = userProfileCache ?: LocalCache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + + val userMetadata = UserMetadataState(signer, cache, scope, settings) + + val nip47SignerState = NwcSignerState(signer, cache, scope, settings) + + val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) + val localRelayList = LocalRelayListState(signer, cache, scope, settings) + + val dmRelayList = DmRelayListState(signer, cache, scope, settings) + + val privateStorageDecryptionCache = PrivateStorageRelayListDecryptionCache(signer) + val privateStorageRelayList = PrivateStorageRelayListState(signer, cache, privateStorageDecryptionCache, scope, settings) + + val searchRelayListDecryptionCache = SearchRelayListDecryptionCache(signer) + val searchRelayList = SearchRelayListState(signer, cache, searchRelayListDecryptionCache, scope, settings) + + val trustedRelayListDecryptionCache = TrustedRelayListDecryptionCache(signer) + val trustedRelayList = TrustedRelayListState(signer, cache, trustedRelayListDecryptionCache, scope, settings) + + val proxyRelayListDecryptionCache = ProxyRelayListDecryptionCache(signer) + val proxyRelayList = ProxyRelayListState(signer, cache, proxyRelayListDecryptionCache, scope, settings) + + val broadcastRelayListDecryptionCache = BroadcastRelayListDecryptionCache(signer) + val broadcastRelayList = BroadcastRelayListState(signer, cache, broadcastRelayListDecryptionCache, scope, settings) + + val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) + val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) + + val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) + val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) + + val kind3FollowList = FollowListState(signer, cache, scope, settings) + + val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer) + val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings) + + val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) + val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) + + val communityListDecryptionCache = CommunityListDecryptionCache(signer) + val communityList = CommunityListState(signer, cache, communityListDecryptionCache, scope, settings) + + val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) + val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) + val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) + + val muteListDecryptionCache = MuteListDecryptionCache(signer) + val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) + + val peopleListDecryptionCache = PeopleListDecryptionCache(signer) + val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) + + val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) + + val bookmarkState = BookmarkListState(signer, cache, scope) + val emoji = EmojiPackState(signer, cache, scope) + + val appSpecific = AppSpecificState(signer, cache, scope, settings) + + val blossomServers = BlossomServerListState(signer, cache, scope, settings) + val fileStorageServers = FileStorageServerListState(signer, cache, scope, settings) + val serverLists = MergedServerListState(fileStorageServers, blossomServers, scope) + + // Relay settings + val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope) + val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope) + val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope) + + val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, trustedRelayList, broadcastRelayList, scope) + + // Follows Relays + val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) + val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, indexerRelayList, scope) + + // keeps a cache of the outbox relays for each author + val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow + + // Merges all follow lists to create a single All Follows feed. + val allFollows = MergedFollowListsState(kind3FollowList, hashtagList, geohashList, communityList, scope) + + val privateDMDecryptionCache = PrivateDMCache(signer) + val privateZapsDecryptionCache = PrivateZapCache(signer) + val draftsDecryptionCache = DraftEventCache(signer) + + val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) + + val privacyState = PrivacyState(settings) + val torRelayState = TorRelayState(trustedRelays, dmRelayList, settings, scope) + + val otsResolverBuilder: OkHttpOtsResolverBuilder = + OkHttpOtsResolverBuilder( + Amethyst.instance.okHttpClients, + privacyState::shouldUseTorForMoneyOperations, + Amethyst.instance.otsBlockHeightCache, + ) + + val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + + val feedDecryptionCaches = + FeedDecryptionCaches( + peopleListCache = peopleListDecryptionCache, + muteListCache = muteListDecryptionCache, + communityListCache = communityListDecryptionCache, + hashtagCache = hashtagListDecryptionCache, + geohashCache = geohashListDecryptionCache, + ) + + // App-ready Feeds + val liveHomeFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultHomeFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveHomeFollowListsPerRelay = OutboxLoaderState(liveHomeFollowLists, cache, scope).flow + + val liveStoriesFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultStoriesFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveStoriesFollowListsPerRelay = OutboxLoaderState(liveStoriesFollowLists, cache, scope).flow + + val liveDiscoveryFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultDiscoveryFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveDiscoveryFollowListsPerRelay = OutboxLoaderState(liveDiscoveryFollowLists, cache, scope).flow + + val liveNotificationFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultNotificationFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow + + /* + val mergedTopFeedAuthorLists = + MergedTopFeedAuthorListsState( + liveHomeFollowListsPerRelay, + liveStoriesFollowListsPerRelay, + liveDiscoveryFollowListsPerRelay, + liveNotificationFollowListsPerRelay, + scope, + ).flow + + */ fun isWriteable(): Boolean = settings.isWriteable() - fun updateWarnReports(warnReports: Boolean): Boolean { + suspend fun updateWarnReports(warnReports: Boolean): Boolean { if (settings.updateWarnReports(warnReports)) { sendNewAppSpecificData() return true @@ -1156,12 +423,10 @@ class Account( return false } - fun updateFilterSpam(filterSpam: Boolean): Boolean { + suspend fun updateFilterSpam(filterSpam: Boolean): Boolean { if (settings.updateFilterSpam(filterSpam)) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { - transientHiddenUsers.update { - emptySet() - } + hiddenUsers.resetTransientUsers() } sendNewAppSpecificData() @@ -1170,22 +435,22 @@ class Account( return false } - fun updateShowSensitiveContent(show: Boolean?) { + suspend fun updateShowSensitiveContent(show: Boolean?) { if (settings.updateShowSensitiveContent(show)) { sendNewAppSpecificData() } } - fun changeReactionTypes(reactionSet: List) { + suspend fun changeReactionTypes(reactionSet: List) { if (settings.changeReactionTypes(reactionSet)) { sendNewAppSpecificData() } } - fun updateZapAmounts( + suspend fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, - nip47Update: Nip47WalletConnect.Nip47URI?, + nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false @@ -1198,18 +463,18 @@ class Account( } } - fun toggleDontTranslateFrom(languageCode: String) { + suspend fun toggleDontTranslateFrom(languageCode: String) { settings.toggleDontTranslateFrom(languageCode) sendNewAppSpecificData() } - fun updateTranslateTo(languageCode: Locale) { + suspend fun updateTranslateTo(languageCode: Locale) { if (settings.updateTranslateTo(languageCode)) { sendNewAppSpecificData() } } - fun prefer( + suspend fun prefer( source: String, target: String, preference: String, @@ -1218,264 +483,36 @@ class Account( sendNewAppSpecificData() } - private fun sendNewAppSpecificData() { - sendNewAppSpecificData(settings.syncedSettings.toInternal()) - } - - private fun sendNewAppSpecificData(toInternal: AccountSyncedSettingsInternal) { - signer.nip44Encrypt(EventMapper.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted -> - AppSpecificDataEvent.create( - dTag = APP_SPECIFIC_DATA_D_TAG, - description = encrypted, - otherTags = emptyArray(), - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun sendKind3RelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.updateRelayList( - earlierVersion = contactList, - relayUse = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(), - followTags = listOf(), - followGeohashes = listOf(), - followCommunities = listOf(), - relayUse = relays, - signer = signer, - ) { - // Keep this local to avoid erasing a good contact list. - // Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - suspend fun countFollowersOf(pubkey: HexKey): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false } - - suspend fun followerCount(): Int = countFollowersOf(signer.pubKey) - - fun sendNewUserMetadata( - name: String? = null, - picture: String? = null, - banner: String? = null, - website: String? = null, - pronouns: String? = null, - about: String? = null, - nip05: String? = null, - lnAddress: String? = null, - lnURL: String? = null, - twitter: String? = null, - mastodon: String? = null, - github: String? = null, - ) { - if (!isWriteable()) return - - val latest = userProfile().latestMetadata - - val template = - if (latest != null) { - MetadataEvent.updateFromPast( - latest = latest, - name = name, - displayName = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - ) - } else { - MetadataEvent.createNew( - name = name, - displayName = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - ) - } - - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun reactionTo( - note: Note, - reaction: String, - ): List = note.reactedBy(userProfile(), reaction) - - fun hasBoosted(note: Note): Boolean = boostsTo(note).isNotEmpty() - - fun boostsTo(note: Note): List = note.boostedBy(userProfile()) - - fun hasReacted( - note: Note, - reaction: String, - ): Boolean = note.hasReacted(userProfile(), reaction) + private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData()) suspend fun reactTo( note: Note, reaction: String, - ) { - if (!isWriteable()) return + ) = ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = signer, + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) - if (hasReacted(note, reaction)) { - // has already liked this note - return - } - - val noteEvent = note.event - if (noteEvent is NIP17Group) { - val users = noteEvent.groupMembers().toList() - - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - - return - } - } - - note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - return - } else { - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - signer.sign( - ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())), - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - return - } - } - - note.toEventHint()?.let { - signer.sign( - ReactionEvent.build(reaction, it), - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - } - - fun createZapRequestFor( - note: Note, + suspend fun createZapRequestFor( + event: Event, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType, toUser: User?, - additionalRelays: Set? = null, - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (!isWriteable()) return - - note.event?.let { event -> - LnZapRequestEvent.create( - event, - relays = getReceivingRelays() + (additionalRelays ?: emptySet()), - signer, - pollOption, - message, - zapType, - toUser?.pubkeyHex, - onReady = onReady, - ) - } - } - - fun getReceivingRelays(): Set = - getNIP65RelayList()?.readRelays()?.toSet() - ?: userProfile() - .latestContactList - ?.relays() - ?.filter { it.value.read } - ?.keys - ?.ifEmpty { null } - ?: settings.localRelays - .filter { it.read } - .map { it.url } - .toSet() - - fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest != null - - fun isNIP47Author(pubkeyHex: String?): Boolean = (getNIP47Signer().pubKey == pubkeyHex) - - fun getNIP47Signer(): NostrSigner = - settings.zapPaymentRequest - ?.secret - ?.hexToByteArray() - ?.let { NostrSignerInternal(KeyPair(it)) } - ?: signer - - fun decryptZapPaymentResponseEvent( - zapResponseEvent: LnZapPaymentResponseEvent, - onReady: (Response) -> Unit, - ) { - val myNip47 = settings.zapPaymentRequest ?: return - - val signer = - myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - zapResponseEvent.response(signer, onReady) - } + additionalRelays: Set? = null, + ) = LnZapRequestEvent.create( + zappedEvent = event, + relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), + signer = signer, + pollOption = pollOption, + message = message, + zapType = zapType, + toUserPubHex = toUser?.pubkeyHex, + ) suspend fun calculateIfNoteWasZappedByAccount( zappedNote: Note?, @@ -1484,204 +521,296 @@ class Account( zappedNote?.isZappedBy(userProfile(), this, onWasZapped) } - suspend fun calculateZappedAmount( - zappedNote: Note?, - onReady: (BigDecimal) -> Unit, - ) { - zappedNote?.zappedAmountWithNWCPayments(getNIP47Signer(), onReady) - } + suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) - fun sendZapPaymentRequestFor( + suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, - onSent: () -> Unit, onResponse: (Response?) -> Unit, ) { - if (!isWriteable()) return - - settings.zapPaymentRequest?.let { nip47 -> - val signer = - nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> - val filter = - NWCPaymentQueryState( - fromServiceHex = nip47.pubKeyHex, - toUserHex = event.pubKey, - replyingToHex = event.id, - ) - - Amethyst.instance.sources.nwc - .subscribe(filter) - - LocalCache.consume(event, zappedNote, true) { it.response(signer) { onResponse(it) } } - - Amethyst.instance.client.sendSingle( - signedEvent = event, - relayTemplate = - RelaySetupInfoToConnect( - url = nip47.relayUri, - forceProxy = shouldUseTorForTrustedRelays(), - read = true, - write = true, - feedTypes = setOf(FeedType.WALLET_CONNECT), - ), - onDone = { - Amethyst.instance.sources.nwc - .unsubscribe(filter) - }, - ) - - onSent() - } - } + val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + client.send(event, setOf(relay)) } - fun createZapRequestFor( - userPubKeyHex: String, + suspend fun createZapRequestFor( + user: User, message: String = "", zapType: LnZapEvent.ZapType, - onReady: (LnZapRequestEvent) -> Unit, - ) { - LnZapRequestEvent.create( - userPubKeyHex, - userProfile() - .latestContactList - ?.relays() - ?.keys - ?.ifEmpty { null } - ?: settings.localRelays.map { it.url }.toSet(), - signer, - message, - zapType, - onReady = onReady, - ) + ): LnZapRequestEvent { + val zapRequest = + LnZapRequestEvent.create( + userHex = user.pubkeyHex, + relays = nip65RelayList.inboxFlow.value + user.inboxRelays(), + signer = signer, + message = message, + zapType = zapType, + ) + + cache.justConsumeMyOwnEvent(zapRequest) + return zapRequest } suspend fun report( note: Note, type: ReportType, content: String = "", - ) { - if (!isWriteable()) return - - if (note.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - note.event?.let { - signer.sign(ReportEvent.build(it, type)) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } + ) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) suspend fun report( user: User, type: ReportType, - ) { + ) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, userProfile(), signer)) + + suspend fun delete(note: Note) = delete(listOf(note)) + + suspend fun delete(notes: List) { if (!isWriteable()) return - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - val template = ReportEvent.build(user.pubkeyHex, type) - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun delete(note: Note) { - delete(listOf(note)) - } - - fun delete(notes: List) { - if (!isWriteable()) return - - val myNoteVersions = notes.filter { it.author == userProfile() }.mapNotNull { it.event as? Event } - if (myNoteVersions.isNotEmpty()) { + val myNotes = notes.filter { it.author == userProfile() && it.event != null } + if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. - myNoteVersions.chunked(200).forEach { chunkedList -> - signer.sign( - DeletionEvent.build(chunkedList), - ) { deletionEvent -> - Amethyst.instance.client.send(deletionEvent) - LocalCache.justConsumeMyOwnEvent(deletionEvent) + myNotes.chunked(200).forEach { chunkedList -> + val template = DeletionEvent.build(chunkedList.mapNotNull { it.event }) + val deletionEvent = signer.sign(template) + val myRelayList = outboxRelays.flow.value.toMutableSet() + chunkedList.forEach { + myRelayList.addAll(it.relays) } + + client.send(deletionEvent, myRelayList) + cache.justConsumeMyOwnEvent(deletionEvent) } } } + suspend fun delete( + event: Event, + additionalRelays: Set, + ) { + if (!isWriteable()) return + if (event.pubKey != signer.pubKey) return + + val deletionEvent = signer.sign(DeletionEvent.build(listOf(event))) + client.send(deletionEvent, outboxRelays.flow.value + additionalRelays) + cache.justConsumeMyOwnEvent(deletionEvent) + } + suspend fun createHTTPAuthorization( url: String, method: String, body: ByteArray? = null, - ): HTTPAuthorizationEvent? { - if (!isWriteable()) return null - - val template = HTTPAuthorizationEvent.build(url, method, body) - - return tryAndWait { continuation -> - signer.sign(template) { - continuation.resume(it) - } - } - } + ): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) suspend fun createBlossomUploadAuth( hash: HexKey, size: Long, alt: String, - ): BlossomAuthorizationEvent? { - if (!isWriteable()) return null - - return tryAndWait { continuation -> - BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) { - continuation.resume(it) - } - } - } + ) = blossomServers.createBlossomUploadAuth(hash, size, alt) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ): BlossomAuthorizationEvent? { - if (!isWriteable()) return null + ) = blossomServers.createBlossomDeleteAuth(hash, alt) - return tryAndWait { continuation -> - BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) { - continuation.resume(it) - } + suspend fun boost(note: Note) { + RepostAction.repost(note, signer)?.let { event -> + client.send(event, computeMyReactionToNote(note, event)) + cache.justConsumeMyOwnEvent(event) } } - suspend fun boost(note: Note) { - if (!isWriteable()) return - val noteEvent = note.event ?: return + fun computeMyReactionToNote( + note: Note, + reaction: Event, + ): Set { + val relaysItCameFrom = note.relays - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return - } + val inboxRelaysOfTheAuthorOfTheOriginalNote = + note.author?.inboxRelays() ?: note.author?.pubkeyHex?.let { + cache.relayHints.hintsForKey(it) + } ?: emptyList() - val noteHint = note.relayHintUrl() - val authorHint = note.author?.bestRelayHint() + val reactionOutBoxRelays = outboxRelays.flow.value - val template = - if (noteEvent.kind == 1) { - RepostEvent.build(noteEvent, noteHint, authorHint) - } else { - GenericRepostEvent.build(noteEvent, noteHint, authorHint) + val taggedUsers = reaction.taggedUserIds() + (note.event?.taggedUserIds() ?: emptyList()) + + val taggedUserInboxRelays = + taggedUsers.flatMapTo(mutableSetOf()) { pubkey -> + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + cache + .getUserIfExists(pubkey) + ?.inboxRelays() + ?.ifEmpty { null } + ?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } } - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) + val channelRelays = cache.getAnyChannel(note)?.relays() ?: emptySet() + + val replyRelays = + note.replyTo?.flatMapTo(mutableSetOf()) { + val existingRelays = it.relays.toSet() + + val replyToAuthor = it.author + + val replyAuthorRelays = + if (replyToAuthor != null) { + if (replyToAuthor == userProfile()) { + outboxRelays.flow.value + } else { + replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints + .hintsForKey(replyToAuthor.pubkeyHex) + .ifEmpty { null } + ?.toSet() + ?: emptySet() + } + } else { + emptySet() + } + + existingRelays + replyAuthorRelays + } ?: emptySet() + + return reactionOutBoxRelays + + inboxRelaysOfTheAuthorOfTheOriginalNote + + taggedUserInboxRelays + + channelRelays + + replyRelays + + relaysItCameFrom + } + + private fun computeRelayListForLinkedUser(user: User): Set = + if (user == userProfile()) { + notificationRelays.flow.value + } else { + user.inboxRelays().ifEmpty { null }?.toSet() + ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys) + } + + private fun computeRelayListForLinkedUser(pubkey: HexKey): Set = + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + cache + .getUserIfExists(pubkey) + ?.inboxRelays() + ?.ifEmpty { null } + ?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } + + private fun computeRelaysForChannels(event: Event): Set = cache.getAnyChannel(event)?.relays() ?: emptySet() + + fun computeRelayListToBroadcast(event: Event): Set { + if (event is MetadataEvent || event is AdvertisedRelayListEvent) { + return followPlusAllMine.flow.value + client.relayStatusFlow().value.available + } + if (event is GiftWrapEvent) { + val receiver = event.recipientPubKey() + if (receiver != null) { + val relayList = + cache + .getOrCreateUser(receiver) + .dmInboxRelayList() + ?.relays() + ?.ifEmpty { null } + if (relayList != null) { + client.send(event, relayList.toSet()) + } else { + val publicRelayList = computeRelayListForLinkedUser(receiver) + client.send(event, publicRelayList) + } + } else { + return emptySet() + } + } + if (event is WrappedEvent) { + return emptySet() + } + + val relayList = mutableSetOf() + + val author = cache.getUserIfExists(event.pubKey) + + if (author != null) { + if (author == userProfile()) { + relayList.addAll(outboxRelays.flow.value) + } else { + relayList.addAll( + author.outboxRelays().ifEmpty { null } + ?: cache.relayHints.hintsForKey(author.pubkeyHex), + ) + } + } else { + relayList.addAll(cache.relayHints.hintsForKey(event.pubKey)) + } + + if (event is PubKeyHintProvider) { + event.pubKeyHints().forEach { + relayList.add(it.relay) + } + event.linkedPubKeys().forEach { pubkey -> + relayList.addAll(computeRelayListForLinkedUser(pubkey)) + } + } + + if (event is EventHintProvider) { + event.eventHints().forEach { + relayList.add(it.relay) + } + event.linkedEventIds().forEach { eventId -> + cache.getNoteIfExists(eventId)?.let { linkedNote -> + val linkedNoteAuthor = linkedNote.author + + if (linkedNoteAuthor != null) { + relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) + } else { + relayList.addAll(linkedNote.relays.toSet()) + } + + linkedNote.event?.let { linkedEvent -> + relayList.addAll(computeRelaysForChannels(linkedEvent)) + } + } + } + } + + if (event is AddressHintProvider) { + event.addressHints().forEach { + relayList.add(it.relay) + } + event.linkedAddressIds().forEach { addressId -> + cache.getAddressableNoteIfExists(addressId)?.let { linkedNote -> + val linkedNoteAuthor = linkedNote.author + + if (linkedNoteAuthor != null) { + relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) + } else { + relayList.addAll(linkedNote.relays.toSet()) + } + + linkedNote.event?.let { linkedEvent -> + relayList.addAll(computeRelaysForChannels(linkedEvent)) + } + } + } + } + + relayList.addAll(computeRelaysForChannels(event)) + + return relayList + } + + fun computeRelayListToBroadcast(note: Note): Set { + val noteEvent = note.event + return if (noteEvent != null) { + computeRelayListToBroadcast(noteEvent) + } else { + note.relays.toSet() } } @@ -1689,401 +818,89 @@ class Account( note.event?.let { if (it is WrappedEvent && it.host != null) { // download the event and send it. - it.host?.let { - Amethyst.instance.client.sendFilterAndStopOnFirstResponse( + it.host?.let { host -> + client.downloadFirstEvent( filters = - listOf( - TypedFilter( - setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL), - SincePerRelayFilter( - ids = listOf(it.id), + note.relays.associateWith { relay -> + listOf( + Filter( + ids = listOf(host.id), ), - ), - ), + ) + }, onResponse = { - Amethyst.instance.client.send(it) + client.send(it, computeRelayListToBroadcast(it)) }, ) } } else { - Amethyst.instance.client.send(it) + client.send(it, computeRelayListToBroadcast(note)) } } } - suspend fun updateAttestations() { - Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") + suspend fun updateAttestations() = sendAutomatic(otsState.updateAttestations()) - val otsResolver = otsResolver() + suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) - settings.pendingAttestations.value.forEach { pair -> - val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key, otsResolver) + suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user)) - if (otsState != null) { - val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint() + suspend fun follow(channel: PublicChatChannel) = sendMyPublicAndPrivateOutbox(publicChatList.follow(channel)) - val template = - if (hint != null) { - OtsEvent.build(hint, otsState) - } else { - OtsEvent.build(pair.key, otsState) - } + suspend fun unfollow(channel: PublicChatChannel) = sendMyPublicAndPrivateOutbox(publicChatList.unfollow(channel)) - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it) + suspend fun follow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.follow(channel)) - settings.pendingAttestations.update { - it - pair.key - } - } - } + suspend fun unfollow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.unfollow(channel)) + + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) + + suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) + + suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.follow(tag)) + + suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) + + suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) + + fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } + + fun sendAutomatic(event: Event?) { + if (event == null) return + cache.justConsumeMyOwnEvent(event) + client.send(event, computeRelayListToBroadcast(event)) + } + + fun sendMyPublicAndPrivateOutbox(event: Event?) { + if (event == null) return + cache.justConsumeMyOwnEvent(event) + client.send(event, outboxRelays.flow.value) + } + + fun sendMyPublicAndPrivateOutbox(events: List) { + events.forEach { + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } - fun hasPendingAttestations(note: Note): Boolean { - val id = note.event?.id ?: note.idHex - return settings.pendingAttestations.value[id] != null + fun sendLiterallyEverywhere(event: Event) { + client.send(event, outboxRelays.flow.value + indexerRelayList.flow.value + client.relayStatusFlow().value.available) + cache.justConsumeMyOwnEvent(event) } - fun timestamp(note: Note) { - if (!isWriteable()) return - if (note.isDraft()) return - - val id = note.event?.id ?: note.idHex - val otsResolver = otsResolver() - - settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver))) - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun follow(channel: PublicChatChannel) { - if (!isWriteable()) return - - publicChatList.follow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun unfollow(channel: PublicChatChannel) { - if (!isWriteable()) return - - publicChatList.unfollow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun follow(channel: EphemeralChatChannel) { - if (!isWriteable()) return - - ephemeralChatList.follow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeInner(it, RelayBriefInfoCache.get(channel.roomId.relayUrl), true) - } - } - - fun unfollow(channel: EphemeralChatChannel) { - if (!isWriteable()) return - - ephemeralChatList.unfollow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeInner(it, RelayBriefInfoCache.get(channel.roomId.relayUrl), true) - } - } - - fun follow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followAddressableEvent(contactList, community.toATag(), signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - val relays = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - } - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = listOf(community.toATag()), - relayUse = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun followHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followHashtag( - contactList, - tag, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = listOf(tag), - followGeohashes = emptyList(), - followCommunities = emptyList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun followGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = listOf(geohash), - followCommunities = emptyList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun onNewEventCreated(event: Event) { - Amethyst.instance.client.send(event) - LocalCache.justConsumeMyOwnEvent(event) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowHashtag( - contactList, - tag, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowAddressableEvent( - contactList, - community.toATag(), - signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun createNip95( + suspend fun createNip95( byteArray: ByteArray, headerInfo: FileHeader, alt: String?, contentWarningReason: String?, - onReady: (Pair) -> Unit, - ) { - if (!isWriteable()) return + ): Pair { + val data = signer.sign(FileStorageEvent.build(byteArray, headerInfo.mimeType)) - signer.sign(FileStorageEvent.build(byteArray, headerInfo.mimeType)) { data -> - val template = - FileStorageHeaderEvent.build(EventHintBundle(data, userProfile().bestRelayHint()), alt) { - hash(headerInfo.hash) - fileSize(headerInfo.size) - - headerInfo.mimeType?.let { mimeType(it) } - headerInfo.dim?.let { dimension(it) } - headerInfo.blurHash?.let { blurhash(it.blurhash) } - - contentWarningReason?.let { contentWarning(contentWarningReason) } - } - - signer.sign(template) { signedEvent -> - onReady( - Pair(data, signedEvent), - ) - } - } - } - - fun consumeAndSendNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ): Note? { - if (!isWriteable()) return null - - Amethyst.instance.client.send(data, relayList = relayList) - LocalCache.justConsumeMyOwnEvent(data) - - Amethyst.instance.client.send(signedEvent, relayList = relayList) - LocalCache.justConsumeMyOwnEvent(signedEvent) - - return LocalCache.getNoteIfExists(signedEvent.id) - } - - fun consumeNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - ): Note? { - LocalCache.justConsumeMyOwnEvent(data) - LocalCache.justConsumeMyOwnEvent(signedEvent) - - return LocalCache.getNoteIfExists(signedEvent.id) - } - - fun sendNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ) { - Amethyst.instance.client.send(data, relayList = relayList) - Amethyst.instance.client.send(signedEvent, relayList = relayList) - } - - fun sendNip95Privately( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ) { - val connect = - relayList.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - - Amethyst.instance.client.sendPrivately(data, relayList = connect) - Amethyst.instance.client.sendPrivately(signedEvent, relayList = connect) - } - - fun sendHeader( - signedEvent: Event, - relayList: List, - onReady: (Note) -> Unit, - ) { - Amethyst.instance.client.send(signedEvent, relayList = relayList) - LocalCache.justConsumeMyOwnEvent(signedEvent) - - LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } - } - - fun createHeader( - imageUrl: String, - magnetUri: String?, - headerInfo: FileHeader, - alt: String?, - contentWarningReason: String? = null, - originalHash: String? = null, - onReady: (FileHeaderEvent) -> Unit, - ) { - if (!isWriteable()) return - - signer.sign( - FileHeaderEvent.build(imageUrl, alt) { + val template = + FileStorageHeaderEvent.build(EventHintBundle(data, userProfile().bestRelayHint()), alt) { hash(headerInfo.hash) fileSize(headerInfo.size) @@ -2091,21 +908,85 @@ class Account( headerInfo.dim?.let { dimension(it) } headerInfo.blurHash?.let { blurhash(it.blurhash) } - originalHash?.let { originalHash(it) } - magnetUri?.let { magnet(it) } - contentWarningReason?.let { contentWarning(contentWarningReason) } - }, - onReady, - ) + } + + val signedEvent = signer.sign(template) + return Pair(data, signedEvent) } - fun sendAllAsOnePictureEvent( + fun consumeAndSendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + ): Note? { + if (!isWriteable()) return null + + val relayList = computeRelayListToBroadcast(signedEvent) + + client.send(data, relayList = relayList) + cache.justConsumeMyOwnEvent(data) + + client.send(signedEvent, relayList = relayList) + cache.justConsumeMyOwnEvent(signedEvent) + + return cache.getNoteIfExists(signedEvent.id) + } + + fun consumeNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + ): Note? { + cache.justConsumeMyOwnEvent(data) + cache.justConsumeMyOwnEvent(signedEvent) + + return cache.getNoteIfExists(signedEvent.id) + } + + fun sendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: Set, + ) { + client.send(data, relayList = relayList) + client.send(signedEvent, relayList = relayList) + } + + fun sendHeader( + signedEvent: Event, + relayList: Set, + onReady: (Note) -> Unit, + ) { + client.send(signedEvent, relayList = relayList) + cache.justConsumeMyOwnEvent(signedEvent) + + cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } + } + + suspend fun sendVoiceMessage( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + ) { + signAndComputeBroadcast(VoiceEvent.build(url, mimeType, hash, duration, waveform)) + } + + suspend fun sendVoiceReplyMessage( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + replyTo: EventHintBundle, + ) { + signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo)) + } + + suspend fun sendAllAsOnePictureEvent( urlHeaderInfo: Map, caption: String?, contentWarningReason: String?, - relayList: List, - onReady: (Note) -> Unit, ) { val iMetas = urlHeaderInfo.map { @@ -2123,7 +1004,7 @@ class Account( ) } - signer.sign( + val template = PictureEvent.build(iMetas, caption ?: "") { caption?.let { hashtags(findHashtags(it)) @@ -2135,21 +1016,18 @@ class Account( // add geohashes // add title contentWarningReason?.let { contentWarning(contentWarningReason) } - }, - ) { - sendHeader(it, relayList = relayList, onReady) - } + } + + signAndComputeBroadcast(template) } - fun sendHeader( + suspend fun sendHeader( url: String, magnetUri: String?, headerInfo: FileHeader, alt: String?, contentWarningReason: String?, originalHash: String? = null, - relayList: List, - onReady: (Note) -> Unit, ) { if (!isWriteable()) return @@ -2216,188 +1094,160 @@ class Account( } } - signer.sign(template) { - sendHeader(it, relayList = relayList, onReady) + signAndComputeBroadcast(template) + } + + suspend fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + client.send(event, relayList) + } + + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): T { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + val relays = relayList(event) + if (relays != null && relays.isNotEmpty()) { + client.send(event, relays.toSet()) + } else { + client.send(event, computeRelayListToBroadcast(event)) + } + return event + } + + suspend fun signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + val note = + if (event is AddressableEvent) { + cache.getOrCreateAddressableNote(event.address()) + } else { + cache.getOrCreateNote(event.id) + } + + val relayList = computeRelayListToBroadcast(note) + + client.send(event, relayList) + + broadcast.forEach { client.send(it, relayList) } + + return event + } + + suspend fun signAndSend( + draftTag: String?, + template: EventTemplate, + relayList: Set, + broadcastNotes: List, + ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) + + suspend fun signAndSend( + draftTag: String?, + template: EventTemplate, + relayList: Set, + broadcastNotes: Set, + ) { + if (draftTag != null) { + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = + DraftEvent.create( + dTag = draftTag, + innerEvent = rumor, + anchorTagArray = emptyList(), + signer = signer, + ) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) + } else { + val it = signer.sign(template) + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList = relayList) + + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } } } } - fun signAndSend( + suspend fun signAndSendNIP04Message( draftTag: String?, template: EventTemplate, + relayList: Set, ) { if (draftTag != null) { if (template.content.isEmpty()) { deleteDraft(draftTag) } else { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } } else { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it) - } + val newEvent = signer.sign(template) + cache.justConsumeMyOwnEvent(newEvent) + client.send(newEvent, relayList) } } - fun signAndSendPrivately( - template: EventTemplate, - relayList: List, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relayList)) - onDone(it) - } - } - - fun signAndSendPrivatelyOrBroadcast( - template: EventTemplate, - relayList: (T) -> List?, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - val relays = relayList(it) - if (relays != null) { - Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relays)) - } else { - Amethyst.instance.client.send(it) - } - onDone(it) - } - } - - fun signAndSend( - template: EventTemplate, - relayList: List, - broadcastNotes: Set, - ) { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it, relayList = relayList) - - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - } - } - - fun signAndSend( + suspend fun signAndSendWithList( draftTag: String?, template: EventTemplate, - relayList: List, - broadcastNotes: List, - ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) - - fun signAndSend( - draftTag: String?, - template: EventTemplate, - relayList: List, + relayList: Collection, broadcastNotes: Set, ) { if (draftTag != null) { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } else { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it, relayList = relayList) + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - } + val relaySet = relayList.toSet() + + client.send(event, relayList = relaySet) + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relaySet) } } } } - fun signAndSendWithList( - draftTag: String?, - template: EventTemplate, - relayList: List, - broadcastNotes: Set, - ) { - if (draftTag != null) { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - signer.sign(template) { - val connect = - relayList.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.sendPrivately(it, relayList = connect) - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.sendPrivately(it, relayList = connect) } } - } - } - } - - fun sendTorrentComment( - draftTag: String?, - template: EventTemplate, - broadcastNotes: Set, - relayList: List, - ) { - if (!isWriteable()) return - - signAndSend(draftTag, template, relayList, broadcastNotes) - } - - fun createAndSendDraft( + suspend fun createAndSendDraft( draftTag: String, template: EventTemplate, ) { - val rumor = signer.assembleRumor(template) - DraftBuilder.encryptAndSign(draftTag, rumor, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftBuilder.encryptAndSign(draftTag, rumor, signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } - fun deleteDraft(draftTag: String) { + suspend fun deleteDraft(draftTag: String) { val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) - LocalCache.getAddressableNoteIfExists(key)?.let { note -> + cache.getAddressableNoteIfExists(key)?.let { note -> val noteEvent = note.event if (noteEvent is DraftEvent) { - noteEvent.createDeletedEvent(signer) { - Amethyst.instance.client.sendPrivately( - it, - note.relays.map { it.url }.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - false, - true, - emptySet(), - ) - }, - ) - LocalCache.justConsumeMyOwnEvent(it) - } + val deletedDraftEvent = noteEvent.createDeletedEvent(signer) + client.send(deletedDraftEvent, outboxRelays.flow.value + note.relays) + cache.justConsumeMyOwnEvent(deletedDraftEvent) + + delete(deletedDraftEvent, note.relays.toSet()) } - delete(note) } } suspend fun createInteractiveStoryReadingState( root: InteractiveStoryBaseEvent, - rootRelay: String?, + rootRelay: NormalizedRelayUrl?, readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: String?, + readingSceneRelay: NormalizedRelayUrl?, ) { if (!isWriteable()) return @@ -2409,15 +1259,13 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template) { - sendToPrivateOutboxAndLocal(it) - } + sendToPrivateOutboxAndLocal(signer.sign(template)) } suspend fun updateInteractiveStoryReadingState( readingState: InteractiveStoryReadingStateEvent, readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: String?, + readingSceneRelay: NormalizedRelayUrl?, ) { if (!isWriteable()) return @@ -2428,9 +1276,7 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template) { - sendToPrivateOutboxAndLocal(it) - } + sendToPrivateOutboxAndLocal(signer.sign(template)) } fun mapEntitiesToNotes(entities: List): List = @@ -2438,10 +1284,10 @@ class Account( when (it) { is NPub -> null is NProfile -> null - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> LocalCache.getOrCreateNote(it.hex) - is NEvent -> LocalCache.getOrCreateNote(it.hex) - is NEmbed -> LocalCache.getOrCreateNote(it.event.id) - is NAddress -> LocalCache.checkGetOrCreateAddressableNote(it.aTag()) + is NNote -> cache.getOrCreateNote(it.hex) + is NEvent -> cache.getOrCreateNote(it.hex) + is NEmbed -> cache.getOrCreateNote(it.event.id) + is NAddress -> cache.checkGetOrCreateAddressableNote(it.aTag()) is NSec -> null is NRelay -> null else -> null @@ -2460,7 +1306,7 @@ class Account( zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2497,7 +1343,7 @@ class Account( zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2526,7 +1372,6 @@ class Account( value: BigDecimal, bounty: Note, draftTag: String?, - relayList: List, ) { if (!isWriteable()) return @@ -2540,59 +1385,41 @@ class Account( eventAuthor.toPTag(), ) - signAndSend(draftTag, template, relayList, setOf(bounty)) + val relays = bounty.relays + outboxRelays.flow.value + + signAndSendWithList(draftTag, template, relays, setOf(bounty)) } - fun sendEdit( + suspend fun sendEdit( message: String, originalNote: Note, notify: HexKey?, summary: String? = null, - relayList: List, + broadcast: List, ) { if (!isWriteable()) return val idHex = originalNote.event?.id ?: return - TextNoteModificationEvent.create( - content = message, - eventId = idHex, - notify = notify, - summary = summary, - signer = signer, - ) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it, relayList = relayList) - } + val event = + TextNoteModificationEvent.create( + content = message, + eventId = idHex, + notify = notify, + summary = summary, + signer = signer, + ) + + cache.justConsumeMyOwnEvent(event) + val note = cache.getOrCreateNote(event.id) + val relayList = computeRelayListToBroadcast(note) + + client.send(event, relayList = relayList) + + broadcast.forEach { client.send(it, relayList) } } - fun sendPrivateMessage( - message: String, - toUser: User, - replyingTo: Note? = null, - zapReceiver: List? = null, - contentWarningReason: String? = null, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String?, - ) { - sendPrivateMessage( - message, - toUser.toPTag(), - replyingTo, - zapReceiver, - contentWarningReason, - zapRaiserAmount, - geohash, - imetas, - emojis, - draftTag, - ) - } - - fun sendPrivateMessage( + suspend fun sendPrivateMessage( message: String, toUser: PTag, replyingTo: Note? = null, @@ -2606,555 +1433,208 @@ class Account( ) { if (!isWriteable()) return - signer.nip04Encrypt( - PrivateDmEvent.prepareMessageToEncrypt(message, imetas), - toUser.pubKey, - ) { encryptedContent -> - val template = - PrivateDmEvent.build(toUser, encryptedContent) { - replyingTo?.let { reply(it.toEId()) } + val encryptedContent = + signer.nip04Encrypt( + PrivateDmEvent.prepareMessageToEncrypt(message, imetas), + toUser.pubKey, + ) - geohash?.let { geohash(it) } - zapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - emojis?.let { emojis(it) } - contentWarningReason?.let { contentWarning(contentWarningReason) } - } + val template = + PrivateDmEvent.build(toUser, encryptedContent) { + replyingTo?.let { reply(it.toEId()) } - signAndSend(draftTag, template) - } + geohash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + emojis?.let { emojis(it) } + contentWarningReason?.let { contentWarning(contentWarningReason) } + } + + val destinationRelays = cache.getOrCreateUser(toUser.pubKey).dmInboxRelays() + + signAndSendNIP04Message(draftTag, template, outboxRelays.flow.value + destinationRelays) } - fun sendNIP17EncryptedFile(template: EventTemplate) { + suspend fun sendNIP17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return - NIP17Factory().createEncryptedFileNIP17(template, signer) { - broadcastPrivately(it) - } + val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer) + broadcastPrivately(wraps) } - fun sendNIP17PrivateMessage( + suspend fun sendNIP17PrivateMessage( template: EventTemplate, draftTag: String? = null, ) { - if (!isWriteable()) return - if (draftTag != null) { if (template.content.isEmpty()) { deleteDraft(draftTag) } else { - signer.assembleRumor(template) { - DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } } else { - NIP17Factory().createMessageNIP17(template, signer) { - broadcastPrivately(it) - } + val it = NIP17Factory().createMessageNIP17(template, signer) + broadcastPrivately(it) } } - fun getPrivateOutBoxRelayList(): List = - normalizedPrivateOutBoxRelaySet.value.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - true, - true, - emptySet(), - ) - } - fun sendDraftEvent(draftEvent: DraftEvent) { sendToPrivateOutboxAndLocal(draftEvent) } - fun sendToPrivateOutboxAndLocal(event: Event) { - val relayList = normalizedPrivateOutBoxRelaySet.value + settings.localRelayServers + fun sendToPrivateOutboxAndLocal(event: Event?) { + if (event == null) return + + val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value if (relayList.isNotEmpty()) { - Amethyst.instance.client.sendPrivately(event, convertRelayList(relayList.toList())) + client.send(event, relayList.toSet()) } else { - Amethyst.instance.client.send(event) + client.send(event, outboxRelays.flow.value) } - LocalCache.justConsumeMyOwnEvent(event) + cache.justConsumeMyOwnEvent(event) } - fun convertRelayList(broadcast: List): List = - broadcast.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - - fun broadcastPrivately(signedEvents: NIP17Factory.Result) { + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } mine.forEach { giftWrap -> - giftWrap.unwrap(signer) { gift -> - if (gift is SealedRumorEvent) { - gift.unseal(signer) { rumor -> - LocalCache.justConsumeMyOwnEvent(rumor) - } + val gift = giftWrap.unwrapOrNull(signer) + if (gift is SealedRumorEvent) { + val rumor = gift.unsealOrNull(signer) + if (rumor != null) { + cache.justConsumeMyOwnEvent(rumor) } - - LocalCache.justConsumeMyOwnEvent(gift) } - LocalCache.justConsumeMyOwnEvent(giftWrap) + if (gift != null) { + cache.justConsumeMyOwnEvent(gift) + } + + cache.justConsumeMyOwnEvent(giftWrap) } val id = mine.firstOrNull()?.id - val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) + val mineNote = if (id == null) null else cache.getNoteIfExists(id) signedEvents.wraps.forEach { wrap -> // Creates an alias if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) { - LocalCache.getOrAddAliasNote(wrap.id, mineNote) + cache.getOrAddAliasNote(wrap.id, mineNote) } - val receiver = wrap.recipientPubKey() - if (receiver != null) { - val relayList = - ( - LocalCache - .getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(receiver)) - ?.event as? ChatMessageRelayListEvent - )?.relays()?.ifEmpty { null }?.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - false, - true, - feedTypes = setOf(FeedType.PRIVATE_DMS), - ) - } - - if (relayList != null) { - Amethyst.instance.client.sendPrivately(signedEvent = wrap, relayList = relayList) - } else { - Amethyst.instance.client.send(wrap) - } - } else { - Amethyst.instance.client.send(wrap) - } + val relayList = computeRelayListToBroadcast(wrap) + client.send(wrap, relayList) } } - fun updateStatus( + suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) + + suspend fun updateStatus( oldStatus: AddressableNote, newStatus: String, - ) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return + ) = sendMyPublicAndPrivateOutbox(UserStatusAction.update(oldStatus, newStatus, signer)) - StatusEvent.update(oldEvent, newStatus, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + suspend fun deleteStatus(oldStatus: AddressableNote) = sendMyPublicAndPrivateOutbox(UserStatusAction.delete(oldStatus, signer)) - fun createStatus(newStatus: String) { - if (!isWriteable()) return + suspend fun removeEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.removeEmojiPack(emojiPack)) - StatusEvent.create(newStatus, "general", expiration = null, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack)) - fun deleteStatus(oldStatus: AddressableNote) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.clear(oldEvent, signer) { event -> - Amethyst.instance.client.send(event) - LocalCache.justConsumeMyOwnEvent(event) - - signer.sign( - DeletionEvent.buildForVersionOnly(listOf(event)), - ) { event2 -> - Amethyst.instance.client.send(event2) - LocalCache.justConsumeMyOwnEvent(event2) - } - } - } - - fun removeEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - if (!isWriteable()) return - - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return - - signer.sign(EmojiPackSelectionEvent.remove(noteEvent, emojiPackEvent)) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun addEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - if (!isWriteable()) return - val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return - - val eventHint = emojiPack.toEventHint() ?: return - - if (usersEmojiList.event == null) { - signer.sign(EmojiPackSelectionEvent.build(listOf(eventHint))) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - - signer.sign(EmojiPackSelectionEvent.add(noteEvent, eventHint)) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun addToGallery( + suspend fun addToGallery( idHex: HexKey, url: String, - relay: String?, + relay: NormalizedRelayUrl?, blurhash: String?, dim: DimensionTag?, hash: String?, mimeType: String?, ) { - if (!isWriteable()) return - - signer.sign( + val template = ProfileGalleryEntryEvent.build(url) { fromEvent(idHex, relay) hash?.let { hash(hash) } mimeType?.let { mimeType(it) } dim?.let { dimension(it) } blurhash?.let { blurhash(it) } - }, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } + } + + val event = signer.sign(template) + sendMyPublicAndPrivateOutbox(event) } - fun removeFromGallery(note: Note) { + suspend fun removeFromGallery(note: Note) { delete(note) } - fun addBookmark( + suspend fun addBookmark( note: Note, isPrivate: Boolean, ) { - if (!isWriteable()) return - if (note.isDraft()) return + if (!isWriteable() || note.isDraft()) return - if (note is AddressableNote) { - BookmarkListEvent.addReplaceable( - userProfile().latestBookmarkList, - note.toATag(), - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - BookmarkListEvent.addEvent( - userProfile().latestBookmarkList, - note.idHex, - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + sendMyPublicAndPrivateOutbox(bookmarkState.addBookmark(note, isPrivate)) } - fun removeBookmark( + suspend fun removeBookmark( note: Note, isPrivate: Boolean, ) { - if (!isWriteable()) return + if (!isWriteable() || note.isDraft()) return - val bookmarks = userProfile().latestBookmarkList ?: return - - if (note is AddressableNote) { - BookmarkListEvent.removeReplaceable( - bookmarks, - note.toATag(), - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - BookmarkListEvent.removeEvent( - bookmarks, - note.idHex, - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } + val event = bookmarkState.removeBookmark(note, isPrivate) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) } } - fun sendAuthEvent( - relay: Relay, + suspend fun sendAuthEvent( + relay: IRelayClient, challenge: String, ) { - createAuthEvent(relay.url, challenge) { - Amethyst.instance.client.sendIfExists(it, relay) - } + val auth = RelayAuthEvent.create(relay.url, challenge, signer) + client.sendIfExists(auth, relay.url) } - fun createAuthEvent( - relayUrl: String, - challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) { - if (!isWriteable()) return - - RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) + suspend fun hideWord(word: String) { + sendMyPublicAndPrivateOutbox(muteList.hideWord(word)) } - fun createAuthEvent( - relayUrls: List, - challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) { - if (!isWriteable()) return - - RelayAuthEvent.create(relayUrls, challenge, signer, onReady = onReady) + suspend fun showWord(word: String) { + sendMyPublicAndPrivateOutbox(blockPeopleList.showWord(word)) + sendMyPublicAndPrivateOutbox(muteList.showWord(word)) } - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - if (!isWriteable()) { - onReady(false) - false - } - if (userProfile().latestBookmarkList == null) { - onReady(false) - false - } - - if (note is AddressableNote) { - userProfile().latestBookmarkList?.privateAddress(signer) { - onReady(it.contains(note.address)) - } - } else { - userProfile().latestBookmarkList?.privateTaggedEvents(signer) { - onReady(it.contains(note.idHex)) - } - } + suspend fun hideUser(pubkeyHex: HexKey) { + sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.isTaggedAddressableNote(note.idHex) == true - } else { - return userProfile().latestBookmarkList?.isTaggedEvent(note.idHex) == true - } + suspend fun showUser(pubkeyHex: HexKey) { + sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) + sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex)) + hiddenUsers.showUser(pubkeyHex) } - fun getAppSpecificDataNote() = LocalCache.getOrCreateAddressableNote(AppSpecificDataEvent.createAddress(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG)) - - fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow - - fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(userProfile().pubkeyHex)) - - fun getMuteListNote() = LocalCache.getOrCreateAddressableNote(MuteListEvent.createAddress(userProfile().pubkeyHex)) - - fun getMuteListFlow(): StateFlow = getMuteListNote().flow().metadata.stateFlow - - fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent - - fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent - - fun hideWord(word: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - MuteListEvent.createListWithWord( - word = word, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun showWord(word: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeWord( - earlierVersion = blockList, - word = word, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeWord( - earlierVersion = muteList, - word = word, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun hideUser(pubkeyHex: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - MuteListEvent.createListWithUser( - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun showUser(pubkeyHex: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeUser( - earlierVersion = blockList, - pubKeyHex = pubkeyHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - transientHiddenUsers.update { - it - pubkeyHex - } - } - - fun requestDVMContentDiscovery( - dvmPublicKey: String, + suspend fun requestDVMContentDiscovery( + dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, ) { - NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) { - val relayList = - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(dvmPublicKey), - )?.event as? AdvertisedRelayListEvent - )?.readRelays()?.ifEmpty { null }?.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } + val relays = nip65RelayList.inboxFlow.value.toSet() + val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) - if (relayList != null) { - Amethyst.instance.client.sendPrivately(it, relayList) - } else { - Amethyst.instance.client.send(it) - } - LocalCache.justConsumeMyOwnEvent(it) - onReady(it) - } - } + val relayList = + dvmPublicKey + .inboxRelays() + .ifEmpty { + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + dvmPublicKey.relaysBeingUsed.keys + }.toSet() - fun unwrap( - event: GiftWrapEvent, - onReady: (Event) -> Unit, - ) { - if (!isWriteable()) return - - return event.unwrap(signer, onReady) - } - - fun unseal( - event: SealedRumorEvent, - onReady: (Event) -> Unit, - ) { - if (!isWriteable()) return - - return event.unseal(signer, onReady) + cache.justConsumeMyOwnEvent(request) + onReady(request) + delay(100) + client.send(request, relayList) } fun cachedDecryptContent(note: Note): String? = cachedDecryptContent(note.event) @@ -3164,11 +1644,11 @@ class Account( return if (isWriteable()) { if (event is PrivateDmEvent) { - event.cachedContentFor(signer) + privateDMDecryptionCache.cachedDM(event) } else if (event is LnZapRequestEvent && event.isPrivateZap()) { - event.cachedPrivateZap()?.content + privateZapsDecryptionCache.cachedPrivateZap(event)?.content } else if (event is DraftEvent) { - event.preCachedDraft(signer)?.content + draftsDecryptionCache.preCachedDraft(event)?.content } else { event.content } @@ -3177,93 +1657,36 @@ class Account( } } - fun decryptContent( - note: Note, - onReady: (String) -> Unit, - ) { + suspend fun decryptContent(note: Note): String? { val event = note.event - if (event is PrivateDmEvent && isWriteable()) { - event.plainContent(signer, onReady) - } else if (event is LnZapRequestEvent) { - decryptZapContentAuthor(note) { onReady(it.content) } - } else if (event is DraftEvent) { - event.cachedDraft(signer) { - onReady(it.content) - } - } else { - event?.content?.let { onReady(it) } - } - } - - fun decryptZapContentAuthor( - note: Note, - onReady: (Event) -> Unit, - ) { - val event = note.event - if (event is LnZapRequestEvent) { + return if (event is PrivateDmEvent && isWriteable()) { + privateDMDecryptionCache.decryptDM(event) + } else if (event is LnZapRequestEvent && isWriteable()) { if (event.isPrivateZap()) { if (isWriteable()) { - event.decryptPrivateZap(signer) { onReady(it) } + privateZapsDecryptionCache.decryptPrivateZap(event)?.content + } else { + null } } else { - onReady(event) + event.content } + } else if (event is DraftEvent && isWriteable()) { + draftsDecryptionCache.cachedDraft(event)?.content + } else { + event?.content } } - // Takes a User's relay list and adds the types of feeds they are active for. - fun kind3Relays(): Array? { - val usersRelayList = - (userProfile().latestContactList ?: settings.backupContactList) - ?.relays() - ?.map { - val url = RelayUrlFormatter.normalize(it.key) - - val localFeedTypes = - settings.localRelays - .firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url } - ?.feedTypes - ?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)) - ?: Constants.defaultRelays - .filter { defaultRelay -> RelayUrlFormatter.normalize(defaultRelay.url) == url } - .firstOrNull() - ?.feedTypes - ?: Constants.activeTypesGlobalChats - - RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes) - }?.ifEmpty { null } ?: return null - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array = - settings.localRelays - .map { - RelaySetupInfo( - RelayUrlFormatter.normalize(it.url), - it.read, - it.write, - it.feedTypes.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)), - ) - }.toTypedArray() - - fun activeGlobalRelays(): Array = - connectToRelays.value - .filter { it.feedTypes.contains(FeedType.GLOBAL) } - .map { it.url } - .toTypedArray() - - fun activeWriteRelays(): List = connectToRelays.value.filter { it.write } + suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null fun isAllHidden(users: Set): Boolean = users.all { isHidden(it) } fun isHidden(user: User) = isHidden(user.pubkeyHex) - fun isHidden(userHex: String): Boolean = - flowHiddenUsers.value.hiddenUsers.contains(userHex) || - flowHiddenUsers.value.spammers.contains(userHex) + fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex) - fun followingKeySet(): Set = liveKind3Follows.value.authors + fun followingKeySet(): Set = kind3FollowList.flow.value.authors fun isAcceptable(user: User): Boolean { if (userProfile().pubkeyHex == user.pubkeyHex) { @@ -3321,234 +1744,43 @@ class Account( } return ( - note.reportsBy(liveKind3Follows.value.authorsPlusMe) + - (note.author?.reportsBy(liveKind3Follows.value.authorsPlusMe) ?: emptyList()) + + note.reportsBy(kind3FollowList.flow.value.authorsPlusMe) + + (note.author?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) + innerReports ).toSet() } - fun saveKind3RelayList(value: List) { - settings.updateLocalRelays(value.toSet()) - sendKind3RelayList( - value.associate { it.url to ReadWrite(it.read, it.write) }, - ) - } + suspend fun saveDMRelayList(dmRelays: List) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) - fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(signer.pubKey)) + suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) - fun getDMRelayListFlow(): StateFlow = getDMRelayListNote().flow().metadata.stateFlow + suspend fun saveSearchRelayList(searchRelays: List) = sendMyPublicAndPrivateOutbox(searchRelayList.saveRelayList(searchRelays)) - fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent + suspend fun saveIndexerRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(indexerRelayList.saveRelayList(trustedRelays)) - fun saveDMRelayList(dmRelays: List) { - if (!isWriteable()) return + suspend fun saveBroadcastRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(broadcastRelayList.saveRelayList(trustedRelays)) - val relayListForDMs = getDMRelayList() - if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { - ChatMessageRelayListEvent.updateRelayList( - earlierVersion = relayListForDMs, - relays = dmRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ChatMessageRelayListEvent.createFromScratch( - relays = dmRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } + suspend fun saveProxyRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(proxyRelayList.saveRelayList(trustedRelays)) - fun getPrivateOutboxRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - PrivateOutboxRelayListEvent.createAddress(signer.pubKey), - ) + suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) - fun getPrivateOutboxRelayListFlow(): StateFlow = getPrivateOutboxRelayListNote().flow().metadata.stateFlow + suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) - fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent + suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) - fun savePrivateOutboxRelayList(relays: List) { - if (!isWriteable()) return + suspend fun sendFileServersList(servers: List) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers)) - val relayListForPrivateOutbox = getPrivateOutboxRelayList() - - if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) { - PrivateOutboxRelayListEvent.updateRelayList( - earlierVersion = relayListForPrivateOutbox, - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - PrivateOutboxRelayListEvent.createFromScratch( - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun getSearchRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - SearchRelayListEvent.createAddress(signer.pubKey), - ) - - fun getSearchRelayListFlow(): StateFlow = getSearchRelayListNote().flow().metadata.stateFlow - - fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent - - fun saveSearchRelayList(searchRelays: List) { - if (!isWriteable()) return - - val relayListForSearch = getSearchRelayList() - - if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { - SearchRelayListEvent.updateRelayList( - earlierVersion = relayListForSearch, - relays = searchRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - SearchRelayListEvent.createFromScratch( - relays = searchRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote = - LocalCache.getOrCreateAddressableNote( - AdvertisedRelayListEvent.createAddress(pubkey), - ) - - fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow - - fun getNIP65RelayList(pubkey: HexKey = signer.pubKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent - - fun sendNip65RelayList(relays: List) { - if (!isWriteable()) return - - val nip65RelayList = getNIP65RelayList() - - if (nip65RelayList != null) { - AdvertisedRelayListEvent.updateRelayList( - earlierVersion = nip65RelayList, - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - AdvertisedRelayListEvent.createFromScratch( - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent - - fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow - - fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddress(userProfile().pubkeyHex)) - - fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent - - fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow - - fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddress(userProfile().pubkeyHex)) - - fun host(url: String): String = - try { - URIReference.parse(url).host.value - } catch (e: Exception) { - url - } - - fun mergeServerList( - nip96: FileServersEvent?, - blossom: BlossomServersEvent?, - ): List { - val nip96servers = nip96?.servers()?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() - val blossomServers = blossom?.servers()?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() - - val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } - - return result + ServerName("NIP95", "", ServerType.NIP95) - } - - fun sendFileServersList(servers: List) { - if (!isWriteable()) return - - val serverList = getFileServersList() - - val template = - if (serverList != null && serverList.tags.isNotEmpty()) { - FileServersEvent.replaceServers(serverList, servers) - } else { - FileServersEvent.build(servers) - } - - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun sendBlossomServersList(servers: List) { - if (!isWriteable()) return - - val serverList = getBlossomServersList() - - if (serverList != null && serverList.tags.isNotEmpty()) { - BlossomServersEvent.updateRelayList( - earlierVersion = serverList, - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - BlossomServersEvent.createFromScratch( - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } + suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) fun getAllPeopleLists(): List = getAllPeopleLists(signer.pubKey) fun getAllPeopleLists(pubkey: HexKey): List = - LocalCache.addressables + cache.addressables .filter { _, addressableNote -> val noteEvent = addressableNote.event if (noteEvent is PeopleListEvent) { - noteEvent.pubKey == pubkey && (noteEvent.hasAnyTaggedUser() || noteEvent.cachedPrivateTags()?.isNotEmpty() == true) + noteEvent.pubKey == pubkey && (noteEvent.hasAnyTaggedUser() || peopleListDecryptionCache.cachedUserIdSet(noteEvent).isNotEmpty()) } else if (noteEvent is FollowListEvent) { noteEvent.pubKey == pubkey && noteEvent.hasAnyTaggedUser() } else { @@ -3575,350 +1807,15 @@ class Account( fun markDonatedInThisVersion() = settings.markDonatedInThisVersion(BuildConfig.VERSION_NAME) - fun shouldUseTorForImageDownload(url: String) = - shouldUseTorFor( - url, - settings.torSettings.torType.value, - settings.torSettings.imagesViaTor.value, - ) - - fun shouldUseTorFor( - url: String, - torType: TorType, - imagesViaTor: Boolean, - ) = when (torType) { - TorType.OFF -> false - TorType.INTERNAL -> shouldUseTor(url, imagesViaTor) - TorType.EXTERNAL -> shouldUseTor(url, imagesViaTor) - } - - private fun shouldUseTor( - normalizedUrl: String, - final: Boolean, - ): Boolean = - if (isLocalHost(normalizedUrl)) { - false - } else if (isOnionUrl(normalizedUrl)) { - true - } else { - final - } - - fun shouldUseTorForVideoDownload() = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> settings.torSettings.videosViaTor.value - TorType.EXTERNAL -> settings.torSettings.videosViaTor.value - } - - fun shouldUseTorForVideoDownload(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) - } - - fun shouldUseTorForPreviewUrl(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) - } - - fun shouldUseTorForTrustedRelays() = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value - TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value - } - - fun shouldUseTorForDirty(dirtyUrl: String) = shouldUseTorForClean(RelayUrlFormatter.normalize(dirtyUrl)) - - fun shouldUseTorForClean(normalizedUrl: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> shouldUseTor(normalizedUrl) - TorType.EXTERNAL -> shouldUseTor(normalizedUrl) - } - - private fun checkLocalHostOnionAndThen( - normalizedUrl: String, - final: Boolean, - ): Boolean = checkLocalHostOnionAndThen(normalizedUrl, settings.torSettings.onionRelaysViaTor.value, final) - - private fun checkLocalHostOnionAndThen( - normalizedUrl: String, - isOnionRelaysActive: Boolean, - final: Boolean, - ): Boolean = - if (isLocalHost(normalizedUrl)) { - false - } else if (isOnionUrl(normalizedUrl)) { - isOnionRelaysActive - } else { - final - } - - private fun shouldUseTor(normalizedUrl: String): Boolean = - if (isLocalHost(normalizedUrl)) { - false - } else if (isOnionUrl(normalizedUrl)) { - settings.torSettings.onionRelaysViaTor.value - } else if (isDMRelay(normalizedUrl)) { - settings.torSettings.dmRelaysViaTor.value - } else if (isTrustedRelay(normalizedUrl)) { - settings.torSettings.trustedRelaysViaTor.value - } else { - settings.torSettings.newRelaysViaTor.value - } - - fun shouldUseTorForMoneyOperations(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) - } - - fun shouldUseTorForNIP05(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) - } - - fun shouldUseTorForNIP96(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) - } - - fun isLocalHost(url: String) = url.contains("//127.0.0.1") || url.contains("//localhost") - - fun isOnionUrl(url: String) = url.contains(".onion") - - fun isDMRelay(url: String) = url in normalizedDmRelaySet.value - - fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri - - fun otsResolver(): OtsResolver = - OtsResolverBuilder().build( - Amethyst.instance.okHttpClients, - ::shouldUseTorForMoneyOperations, - Amethyst.instance.otsBlockHeightCache, - ) - init { Log.d("AccountRegisterObservers", "Init") - settings.backupContactList?.let { - Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupUserMetadata?.let { - Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupDMRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupNIP65RelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupSearchRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupPrivateHomeRelayList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - settings.backupAppSpecificData?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - LocalCache.justConsumeMyOwnEvent(event) - signer.decrypt(event.content, event.pubKey) { decrypted -> - try { - val syncedSettings = EventMapper.mapper.readValue(decrypted) - settings.syncedSettings.updateFrom(syncedSettings) - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } - } - } - } - - settings.backupMuteList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - settings.backupEphemeralChatList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - settings.backupChannelList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - // saves contact list for the next time. - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Kind 0 Collector Start") - userProfile().flow().metadata.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 0 ${userProfile().toBestDisplayName()}") - settings.updateUserMetadata(userProfile().latestMetadata) - } - } - - // saves contact list for the next time. - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Kind 3 Collector Start") - userProfile().flow().follows.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 3 ${userProfile().toBestDisplayName()}") - settings.updateContactListTo(userProfile().latestContactList) - } - } scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") - getDMRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating DM Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? ChatMessageRelayListEvent)?.let { - settings.updateDMRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") - getNIP65RelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${userProfile().toBestDisplayName()}") - (it.note.event as? AdvertisedRelayListEvent)?.let { - settings.updateNIP65RelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Search Relay List Collector Start") - getSearchRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Search Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? SearchRelayListEvent)?.let { - settings.updateSearchRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") - getPrivateOutboxRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? PrivateOutboxRelayListEvent)?.let { - settings.updatePrivateHomeRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Mute List Collector Start") - getMuteListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Mute List for ${userProfile().toBestDisplayName()}") - (it.note.event as? MuteListEvent)?.let { - settings.updateMuteList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Channel List Collector Start") - publicChatList.getChannelListFlow().collect { - Log.d("AccountRegisterObservers", "Channel List for ${userProfile().toBestDisplayName()}") - (it.note.event as? ChannelListEvent)?.let { - settings.updateChannelListTo(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start") - ephemeralChatList.getEphemeralChatListFlow().collect { - Log.d("AccountRegisterObservers", "EphemeralChatList List for ${userProfile().toBestDisplayName()}") - (it.note.event as? EphemeralChatListEvent)?.let { - settings.updateEphemeralChatListTo(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") - getAppSpecificDataFlow().collect { - Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${userProfile().toBestDisplayName()}") - (it.note.event as? AppSpecificDataEvent)?.let { - signer.decrypt(it.content, it.pubKey) { decrypted -> - val syncedSettings = - try { - EventMapper.mapper.readValue(decrypted) - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } - - settings.updateAppSpecificData(it, syncedSettings) - } - } - } - } - - scope.launch(Dispatchers.Default) { - LocalCache.antiSpam.flowSpam.collect { + cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> - if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) { + if (!hiddenUsers.isHidden(spammer.pubkeyHex) && spammer.shouldHide()) { if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) { - transientHiddenUsers.update { - it + spammer.pubkeyHex - } + hiddenUsers.hideUser(spammer.pubkeyHex) } } } @@ -3926,14 +1823,15 @@ class Account( } scope.launch(Dispatchers.Default) { + Log.d("DB UPGRADE", "Migrating List") delay(1000 * 60 * 1) // waits 5 minutes before migrating the list. val contactList = userProfile().latestContactList - val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { LocalCache.getChannelIfExists(it) as? PublicChatChannel } + val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { cache.getPublicChatChannelIfExists(it) } if (oldChannels != null && oldChannels.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List with ${oldChannels.size} old channels ") - val existingChannels = publicChatList.livePublicChatEventIdSet.value + val existingChannels = publicChatList.flowSet.value val needsToUpgrade = oldChannels.filter { it.idHex !in existingChannels } @@ -3941,9 +1839,61 @@ class Account( if (needsToUpgrade.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List") - publicChatList.follow(oldChannels) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeMyOwnEvent(it) + runCatching { + sendMyPublicAndPrivateOutbox(publicChatList.follow(oldChannels)) + } + } + } + + val oldCommunities = contactList?.taggedAddresses()?.toSet()?.map { cache.getOrCreateAddressableNote(it) } + + if (oldCommunities != null && oldCommunities.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldCommunities.size} old communities ") + val existingCommunities = communityList.flowSet.value + + val needsToUpgrade = oldCommunities.filter { it.idHex !in existingCommunities } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(communityList.follow(oldCommunities)) + } + } + } + + val oldHashtags = contactList?.hashtags()?.toSet() + + if (oldHashtags != null && oldHashtags.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldHashtags.size} old hashtags ") + val existingHashtags = hashtagList.flow.value + + val needsToUpgrade = oldHashtags.filter { it !in existingHashtags } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(hashtagList.follow(oldHashtags.toList())) + } + } + } + + val oldGeohashes = contactList?.geohashes()?.toSet() + if (oldGeohashes != null && oldGeohashes.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldGeohashes.size} old geohashes ") + val existingGeohashes = geohashList.flow.value + + val needsToUpgrade = oldGeohashes.filter { it !in existingGeohashes } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(geohashList.follow(oldGeohashes.toList())) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 372df358d4..120281127a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -20,34 +20,41 @@ */ package com.vitorpamplona.amethyst.model -import android.util.Log +import android.content.ContentResolver import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -56,49 +63,45 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import java.util.Locale -val DefaultChannelSet = - setOf( - // Anigma's Nostr - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", - // Amethyst's Group - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", - ) - val DefaultChannels = listOf( // Anigma's Nostr - EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", "wss://nos.lol"), + ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos), // Amethyst's Group - EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", "wss://nos.lol"), + ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos), ) +val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner) + val DefaultNIP65List = listOf( - AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), - AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), - AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH), ) -val DefaultDMRelayList = - listOf( - RelayUrlFormatter.normalize("wss://auth.nostr1.com"), - RelayUrlFormatter.normalize("wss://relay.0xchat.com"), - RelayUrlFormatter.normalize("wss://nos.lol"), - ) +val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos) -val DefaultSearchRelayList = +val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday) + +val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds) + +val DefaultSignerPermissions = listOf( - RelayUrlFormatter.normalize("wss://relay.nostr.band"), - RelayUrlFormatter.normalize("wss://nostr.wine"), - RelayUrlFormatter.normalize("wss://relay.noswhere.com"), - RelayUrlFormatter.normalize("wss://search.nos.today"), + Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND), + Permission(CommandType.SIGN_EVENT, DraftEvent.KIND), + Permission(CommandType.NIP04_ENCRYPT), + Permission(CommandType.NIP04_DECRYPT), + Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.DECRYPT_ZAP_EVENT), ) // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val KIND3_FOLLOWS = " All Follows " +val ALL_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val AROUND_ME = " Around Me " @@ -108,14 +111,13 @@ class AccountSettings( val keyPair: KeyPair, val transientAccount: Boolean = false, var externalSignerPackageName: String? = null, - var localRelays: Set = Constants.defaultRelays.toSet(), - var localRelayServers: Set = setOf(), + var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], - val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), + val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(ALL_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null, + var zapPaymentRequest: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -124,10 +126,15 @@ class AccountSettings( var backupDMRelayList: ChatMessageRelayListEvent? = null, var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null, + var backupBlockedRelayList: BlockedRelayListEvent? = null, + var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null, var backupAppSpecificData: AppSpecificDataEvent? = null, var backupChannelList: ChannelListEvent? = null, + var backupCommunityList: CommunityListEvent? = null, + var backupHashtagList: HashtagListEvent? = null, + var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, val torSettings: TorSettingsFlow = TorSettingsFlow(), val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), @@ -147,25 +154,18 @@ class AccountSettings( fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null - fun createSigner() = + fun createSigner(contentResolver: ContentResolver) = if (keyPair.privKey != null) { NostrSignerInternal(keyPair) } else { when (val packageName = externalSignerPackageName) { null -> NostrSignerInternal(keyPair) - else -> { - val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName) - // TODO: How to handle the launcher here? - try { - externalSignerLauncher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } catch (e: Exception) { - Log.d("AccountSettings", "Failed to initialize external signer", e) - } - NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher) - } + else -> + NostrSignerExternal( + pubKey = keyPair.pubKey.toHexKey(), + packageName = packageName, + contentResolver = contentResolver, + ) } } @@ -200,9 +200,9 @@ class AccountSettings( return false } - fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean { - if (zapPaymentRequest != newServer) { - zapPaymentRequest = newServer + fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean { + if (zapPaymentRequest.value != newServer) { + zapPaymentRequest.tryEmit(newServer) saveAccountSettings() return true } @@ -224,6 +224,10 @@ class AccountSettings( // list names // --- + fun changeDefaultHomeFollowList(name: FeedDefinition) { + changeDefaultHomeFollowList(name.code) + } + fun changeDefaultHomeFollowList(name: String) { if (defaultHomeFollowList.value != name) { defaultHomeFollowList.tryEmit(name) @@ -231,6 +235,10 @@ class AccountSettings( } } + fun changeDefaultStoriesFollowList(name: FeedDefinition) { + changeDefaultStoriesFollowList(name.code) + } + fun changeDefaultStoriesFollowList(name: String) { if (defaultStoriesFollowList.value != name) { defaultStoriesFollowList.tryEmit(name) @@ -238,6 +246,10 @@ class AccountSettings( } } + fun changeDefaultNotificationFollowList(name: FeedDefinition) { + changeDefaultNotificationFollowList(name.code) + } + fun changeDefaultNotificationFollowList(name: String) { if (defaultNotificationFollowList.value != name) { defaultNotificationFollowList.tryEmit(name) @@ -245,6 +257,10 @@ class AccountSettings( } } + fun changeDefaultDiscoveryFollowList(name: FeedDefinition) { + changeDefaultDiscoveryFollowList(name.code) + } + fun changeDefaultDiscoveryFollowList(name: String) { if (defaultDiscoveryFollowList.value != name) { defaultDiscoveryFollowList.tryEmit(name) @@ -255,14 +271,13 @@ class AccountSettings( // --- // proxy settings // --- - fun setTorSettings(newTorSettings: TorSettings): Boolean { + fun setTorSettings(newTorSettings: TorSettings): Boolean = if (torSettings.update(newTorSettings)) { saveAccountSettings() - return true + true } else { - return false + false } - } // --- // language services @@ -301,8 +316,8 @@ class AccountSettings( // ---- fun updateLocalRelayServers(servers: Set) { - if (localRelayServers != servers) { - localRelayServers = servers + if (localRelayServers.value != servers) { + localRelayServers.update { servers } saveAccountSettings() } } @@ -357,6 +372,26 @@ class AccountSettings( } } + fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) { + if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupBlockedRelayList?.id != newBlockedRelayList.id) { + backupBlockedRelayList = newBlockedRelayList + saveAccountSettings() + } + } + + fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) { + if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupTrustedRelayList?.id != newTrustedRelayList.id) { + backupTrustedRelayList = newTrustedRelayList + saveAccountSettings() + } + } + fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) { if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return @@ -377,6 +412,36 @@ class AccountSettings( } } + fun updateGeohashListTo(newGeohashList: GeohashListEvent?) { + if (newGeohashList == null || newGeohashList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupGeohashList?.id != newGeohashList.id) { + backupGeohashList = newGeohashList + saveAccountSettings() + } + } + + fun updateHashtagListTo(newHashtagList: HashtagListEvent?) { + if (newHashtagList == null || newHashtagList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupHashtagList?.id != newHashtagList.id) { + backupHashtagList = newHashtagList + saveAccountSettings() + } + } + + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { + if (newCommunityList == null || newCommunityList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupCommunityList?.id != newCommunityList.id) { + backupCommunityList = newCommunityList + saveAccountSettings() + } + } + fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) { if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return @@ -492,17 +557,6 @@ class AccountSettings( } } - // ---- - // local relays - // ---- - - fun updateLocalRelays(newLocalRelays: Set) { - if (!localRelays.equals(newLocalRelays)) { - localRelays = newLocalRelays - saveAccountSettings() - } - } - // --- // attestations // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 8ee4e293f0..16d1a76260 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -114,9 +114,7 @@ class AccountSyncedSettings( } } - fun dontTranslateFromFilteredBySpokenLanguages(): Set { - return languages.dontTranslateFrom - getLanguagesSpokenByUser() - } + fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom - getLanguagesSpokenByUser() } @Stable @@ -140,11 +138,12 @@ class AccountLanguagePreferences( // language services // --- fun toggleDontTranslateFrom(languageCode: String) { - if (!dontTranslateFrom.contains(languageCode)) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - } else { - dontTranslateFrom = dontTranslateFrom.minus(languageCode) - } + dontTranslateFrom = + if (!dontTranslateFrom.contains(languageCode)) { + dontTranslateFrom.plus(languageCode) + } else { + dontTranslateFrom.minus(languageCode) + } } fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index ed7d2b66df..4b2defdcb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -22,75 +22,114 @@ package com.vitorpamplona.amethyst.model import android.util.Log import android.util.LruCache -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.njumpLink -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import kotlinx.coroutines.flow.MutableStateFlow data class Spammer( val pubkeyHex: HexKey, - var duplicatedMessages: Set, -) + var duplicatedEventIds: Set, + var duplicatedEventAddresses: Set
, +) { + fun shouldHide() = duplicatedEventIds.size >= 5 || duplicatedEventAddresses.size >= 5 +} class AntiSpamFilter { - val recentMessages = LruCache(1000) + val recentEventIds = LruCache(2000) + val recentAddressables = LruCache(2000) val spamMessages = LruCache(1000) var active: Boolean = true + fun spamHashCode(event: Event): Int = 31 * event.content.hashCode() + event.tags.contentDeepHashCode() + fun isSpam( event: Event, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, ): Boolean { - checkNotInMainThread() - if (!active) return false - val idHex = event.id - // if short message, ok // The idea here is to avoid considering repeated "GM" messages spam. - if (event.content.length < 50) return false + if (event.content.length < 60) return false // if the message is actually short but because it cites a user/event, the nostr: string is // really long, make it ok. // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still // blocking repeated "lnbc..." invoices or fishing urls - if (event.content.length < 180 && Nip19Parser.nip19regex.matcher(event.content).find()) return false + if (event.content.length < 180 && event.content.startsWith("nostr:") && Nip19Parser.nip19regex.matcher(event.content).find()) return false // double list strategy: // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. // Considers tags so that same replies to different people don't count. - val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() + val hash = spamHashCode(event) - if ( - (recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null - ) { - Log.w( - "Potential SPAM Message", - "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}", - ) + // ignores multiple versions of the same addressable. + if (event is AddressableEvent) { + val address = event.address() - // Log down offenders - logOffender(hash, event) + // normal event + if ( + (recentAddressables[hash] != null && recentAddressables[hash] != address) || + (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address)) + ) { + val existingAddress = recentAddressables[hash] - if (relay != null) { - RelayStats.newSpam(relay.url, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay.url))) + val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay)) + val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay)) + + Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2") + + // Log down offenders + val spammer = logOffender(hash, event) + + if (spammer.shouldHide() && relay != null) { + RelayStats.newSpam(relay, "$link1 $link2") + } + + flowSpam.tryEmit(AntiSpamState(this)) + + return true } - flowSpam.tryEmit(AntiSpamState(this)) + recentAddressables.put(hash, address) + } else { + // normal event + if ( + (recentEventIds[hash] != null && recentEventIds[hash] != event.id) || + (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id)) + ) { + val existingEvent = recentEventIds[hash] - return true + val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay)) + val link2 = njumpLink(NEvent.create(event.id, null, null, relay)) + + Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2") + + // Log down offenders + val spammer = logOffender(hash, event) + + if (spammer.shouldHide() && relay != null) { + RelayStats.newSpam(relay, "$link1 $link2") + } + + flowSpam.tryEmit(AntiSpamState(this)) + + return true + } + + recentEventIds.put(hash, event.id) } - recentMessages.put(hash, idHex) - return false } @@ -98,12 +137,33 @@ class AntiSpamFilter { private fun logOffender( hashCode: Int, event: Event, - ) { - if (spamMessages.get(hashCode) == null) { - spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id))) + ): Spammer { + val spammer = spamMessages.get(hashCode) + + if (spammer == null) { + val newSpammer = + if (event is AddressableEvent) { + Spammer( + pubkeyHex = event.pubKey, + duplicatedEventIds = setOf(), + duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()), + ) + } else { + Spammer( + pubkeyHex = event.pubKey, + duplicatedEventIds = setOf(recentEventIds[hashCode], event.id), + duplicatedEventAddresses = setOf(), + ) + } + spamMessages.put(hashCode, newSpammer) + return newSpammer } else { - val spammer = spamMessages.get(hashCode) - spammer.duplicatedMessages += event.id + if (event is AddressableEvent) { + spammer.duplicatedEventAddresses += event.address() + } else { + spammer.duplicatedEventIds += event.id + } + return spammer } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index fd604bfbe1..153f1cd730 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -22,197 +22,40 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.amethyst.ui.note.toShortenHex -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress -import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.toNEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.LargeCache import kotlinx.coroutines.flow.MutableStateFlow @Stable -class EphemeralChatChannel( - val roomId: RoomId, -) : Channel(roomId.toKey()) { - override fun idNote() = roomId.toDisplayKey() - - override fun idDisplayNote() = idNote().toShortenHex() - - override fun relays() = listOf(roomId.relayUrl) - - override fun toBestDisplayName() = roomId.toDisplayKey() - - override fun summary(): String? = null - - override fun profilePicture(): String? = null - - override fun anyNameStartsWith(prefix: String): Boolean = roomId.id.contains(prefix, true) -} - -@Stable -class PublicChatChannel( - idHex: String, -) : Channel(idHex) { - var event: ChannelCreateEvent? = null - var infoTags = EmptyTagList - var info = ChannelData(null, null, null, null) - - override fun relays() = info.relays ?: super.relays() - - fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, *relays().toTypedArray()) - - fun toNostrUri() = "nostr:${toNEvent()}" - - fun toEventHint() = event?.let { EventHintBundle(it, relays().firstOrNull(), null) } - - fun toEventId() = EventIdHint(idHex, relays().firstOrNull()) - - fun updateChannelInfo( - creator: User, - event: ChannelCreateEvent, - ) { - this.event = event - this.infoTags = event.tags.toImmutableListOfLists() - updateChannelInfo(creator, event.channelInfo(), event.createdAt) - } - - fun updateChannelInfo( - creator: User, - event: ChannelMetadataEvent, - ) { - this.infoTags = event.tags.toImmutableListOfLists() - updateChannelInfo(creator, event.channelInfo(), event.createdAt) - } - - fun updateChannelInfo( - creator: User, - channelInfo: ChannelData, - updatedAt: Long, - ) { - this.info = channelInfo - super.updateChannelInfo(creator, updatedAt) - } - - override fun toBestDisplayName(): String = info.name ?: super.toBestDisplayName() - - override fun summary(): String? = info.about - - override fun profilePicture(): String? { - if (info.picture.isNullOrBlank()) return super.profilePicture() - return info.picture ?: super.profilePicture() - } - - override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).any { it.contains(prefix, true) } -} - -@Stable -class LiveActivitiesChannel( - val address: Address, -) : Channel(address.toValue()) { - var info: LiveActivitiesEvent? = null - - override fun idNote() = toNAddr() - - override fun idDisplayNote() = idNote().toShortenHex() - - fun address() = address - - override fun relays() = info?.allRelayUrls() ?: super.relays() - - fun relayHintUrl() = relays().firstOrNull() - - fun updateChannelInfo( - creator: User, - channelInfo: LiveActivitiesEvent, - updatedAt: Long, - ) { - this.info = channelInfo - super.updateChannelInfo(creator, updatedAt) - } - - override fun toBestDisplayName(): String = info?.title() ?: super.toBestDisplayName() - - override fun summary(): String? = info?.summary() - - override fun profilePicture(): String? = info?.image()?.ifBlank { null } - - override fun anyNameStartsWith(prefix: String): Boolean = - listOfNotNull(info?.title(), info?.summary()) - .filter { it.contains(prefix, true) } - .isNotEmpty() - - fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, *relays().toTypedArray()) - - fun toATag() = ATag(address, relayHintUrl()) - - fun toNostrUri() = "nostr:${toNAddr()}" -} - -data class Counter( - var number: Int = 0, -) - -@Stable -abstract class Channel( - val idHex: String, -) { - var creator: User? = null - var updatedMetadataAt: Long = 0 +abstract class Channel : NotesGatherer { val notes = LargeCache() - var lastNoteCreatedAt: Long = 0 - private var relays = mapOf() + var lastNote: Note? = null - open fun idNote() = Hex.decode(idHex).toNEvent() + private var relays = mapOf() - open fun idDisplayNote() = idNote().toShortenHex() + abstract fun toBestDisplayName(): String - open fun toBestDisplayName(): String = idDisplayNote() - - open fun summary(): String? = null - - open fun creatorName(): String? = creator?.toBestDisplayName() - - open fun profilePicture(): String? = creator?.info?.banner - - open fun relays() = + open fun relays(): Set = relays.keys .toSortedSet { o1, o2 -> val o1Count = relays[o1]?.number ?: 0 val o2Count = relays[o2]?.number ?: 0 o2Count.compareTo(o1Count) // descending - }.map { it.url } - - open fun updateChannelInfo( - creator: User, - updatedAt: Long, - ) { - this.creator = creator - this.updatedMetadataAt = updatedAt + } + fun updateChannelInfo() { flowSet?.metadata?.invalidateData() } @Synchronized - fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { + fun addRelaySync(briefInfo: NormalizedRelayUrl) { if (briefInfo !in relays) { relays = relays + Pair(briefInfo, Counter(1)) } } - fun addRelay(relay: RelayBriefInfoCache.RelayBriefInfo) { + fun addRelay(relay: NormalizedRelayUrl) { val counter = relays[relay] if (counter != null) { counter.number++ @@ -223,31 +66,37 @@ abstract class Channel( fun addNote( note: Note, - relay: RelayBriefInfoCache.RelayBriefInfo? = null, + relay: NormalizedRelayUrl? = null, ) { - notes.put(note.idHex, note) + if (!notes.containsKey(note.idHex)) { + notes.put(note.idHex, note) + note.addGatherer(this) - if ((note.createdAt() ?: 0) > lastNoteCreatedAt) { - lastNoteCreatedAt = note.createdAt() ?: 0 + if ((note.createdAt() ?: 0) > (lastNote?.createdAt() ?: 0)) { + lastNote = note + } + + if (relay != null) { + addRelay(relay) + } + + flowSet?.notes?.invalidateData() } + } - if (relay != null) { - addRelay(relay) + override fun removeNote(note: Note) { + if (notes.containsKey(note.idHex)) { + notes.remove(note.idHex) + note.removeGatherer(this) + + if (note == lastNote) { + lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull() + } + + flowSet?.notes?.invalidateData() } - - flowSet?.notes?.invalidateData() } - fun removeNote(note: Note) { - notes.remove(note.idHex) - } - - fun removeNote(noteHex: String) { - notes.remove(noteHex) - } - - abstract fun anyNameStartsWith(prefix: String): Boolean - fun pruneOldMessages(): Set { val important = notes @@ -308,6 +157,10 @@ abstract class Channel( } } +data class Counter( + var number: Int = 0, +) + @Stable class ChannelFlowSet( u: Channel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt new file mode 100644 index 0000000000..d34c7d42c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +object Constants { + val nos = RelayUrlNormalizer.normalize("wss://nos.lol") + val mom = RelayUrlNormalizer.normalize("wss://nostr.mom") + val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net") + val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io") + val wine = RelayUrlNormalizer.normalize("wss://nostr.wine") + val band = RelayUrlNormalizer.normalize("wss://relay.nostr.band") + + val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com") + val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org") + + val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social") + val bg = RelayUrlNormalizer.normalize("wss://relay.nostr.bg") + val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev") + val fmtwiz = RelayUrlNormalizer.normalize("wss://nostr.fmt.wiz.biz") + + val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today") + + val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com") + val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com") + + val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es") + val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social") + val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es") + + val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz, bg) + + val defaultSearchRelaySet = setOf(band, wine, where) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 421462e607..567b61ac3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -49,20 +49,22 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.ui.components.HashTag import com.vitorpamplona.amethyst.ui.components.RenderRegular -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @Preview @Composable fun RenderHashTagIconsPreview() { + val accountViewModel = mockAccountViewModel() ThemeComparisonColumn { RenderRegular( "Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain", EmptyTagList, ) { word, state -> when (word) { - is HashTagSegment -> HashTag(word, EmptyNav) + is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav) is RegularTextSegment -> Text(word.segmentText) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 184e01dfb8..2d53efc172 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -24,17 +24,19 @@ import android.util.Log import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag +import com.vitorpamplona.amethyst.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -47,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.checkSignature @@ -55,7 +58,14 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.tagValueContains +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.mapTaggedAddress @@ -70,21 +80,30 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +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.NNote +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.isATag import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -104,6 +123,7 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore @@ -111,12 +131,19 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent @@ -134,13 +161,13 @@ import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -153,16 +180,19 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.LargeCache +import com.vitorpamplona.quartz.utils.LargeSoftCache import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -170,34 +200,36 @@ import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.concurrent.ConcurrentHashMap interface ILocalCache { fun markAsSeen( - string: String, - relay: RelayBriefInfoCache.RelayBriefInfo, + eventId: String, + relay: NormalizedRelayUrl, ) {} } object LocalCache : ILocalCache { val antiSpam = AntiSpamFilter() - val users = LargeCache() - val notes = LargeCache() - val addressables = LargeCache() - val channels = LargeCache() - val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val users = LargeSoftCache() + val notes = LargeSoftCache() + val addressables = LargeSoftCache() + + val chatroomList = LargeCache() + val publicChatChannels = LargeCache() + val liveChatChannels = LargeCache() + val ephemeralChannels = LargeCache() + + val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + + val relayHints = HintIndexer() val deletionIndex = DeletionIndex() val observablesByKindAndETag = ConcurrentHashMap>>(10) val observablesByKindAndAuthor = ConcurrentHashMap>>(10) - val onNewEvents = mutableListOf<(Note) -> Unit>() - fun observeETag( kind: Int, eventId: HexKey, @@ -286,17 +318,19 @@ object LocalCache : ILocalCache { return users.get(key) } - fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key) + fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) } - fun getAddressableNoteIfExists(address: Address): AddressableNote? = getAddressableNoteIfExists(address.toValue()) + fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) - fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key) + fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) - fun getChannelIfExists(key: String): Channel? = channels.get(key) + fun getPublicChatChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key) - fun getChannelIfExists(key: RoomId): Channel? = channels.get(key.toKey()) + fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key) + + fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key) fun getNoteIfExists(event: Event): Note? = if (event is AddressableEvent) { @@ -313,8 +347,6 @@ object LocalCache : ILocalCache { } fun checkGetOrCreateNote(etag: ETag): Note? { - checkNotInMainThread() - if (isValidHex(etag.eventId)) { return getOrCreateNote(etag) } @@ -322,8 +354,6 @@ object LocalCache : ILocalCache { } fun checkGetOrCreateNote(key: String): Note? { - checkNotInMainThread() - if (ATag.isATag(key)) { return checkGetOrCreateAddressableNote(key) } @@ -352,8 +382,6 @@ object LocalCache : ILocalCache { idHex: String, note: Note, ): Note { - checkNotInMainThread() - require(isValidHex(idHex)) { "$idHex is not a valid hex" } return notes.getOrCreate(idHex) { @@ -362,8 +390,6 @@ object LocalCache : ILocalCache { } fun getOrCreateNote(idHex: String): Note { - checkNotInMainThread() - require(isValidHex(idHex)) { "$idHex is not a valid hex" } return notes.getOrCreate(idHex) { @@ -371,43 +397,24 @@ object LocalCache : ILocalCache { } } - fun getOrCreateChannel( - key: String, - channelFactory: (String) -> Channel, - ): Channel { - checkNotInMainThread() + fun getOrCreateChatroomList(key: HexKey): ChatroomList = chatroomList.getOrCreate(key) { ChatroomList(key) } - return channels.getOrCreate(key, channelFactory) - } + fun getOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = publicChatChannels.getOrCreate(key) { PublicChatChannel(key) } - fun checkGetOrCreateChannel(key: RoomId): Channel? = - channels.getOrCreate(key.toKey()) { - EphemeralChatChannel(key) - } + fun getOrCreateLiveChannel(key: Address): LiveActivitiesChannel = liveChatChannels.getOrCreate(key) { LiveActivitiesChannel(key) } - fun checkGetOrCreateChannel(key: String): Channel? { - checkNotInMainThread() - - if (key.contains("@")) { - return channels.getOrCreate(key) { - val idParts = key.split("@") - EphemeralChatChannel(RoomId(idParts[0], idParts[1])) - } - } + fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) } + fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { if (isValidHex(key)) { - return channels.getOrCreate(key) { PublicChatChannel(key) } - } - - val address = Address.parse(key) - if (address != null) { - return channels.getOrCreate(address.toValue()) { LiveActivitiesChannel(address) } + return getOrCreatePublicChatChannel(key) } return null } private fun isValidHex(key: String): Boolean { if (key.isBlank()) return false + if (key.length != 64) return false if (key.contains(":")) return false return Hex.isHex(key) @@ -426,10 +433,7 @@ object LocalCache : ILocalCache { null } - fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = - addressables.getOrCreate(key.toValue()) { - AddressableNote(key) - } + fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) } fun getOrCreateAddressableNote(key: Address): AddressableNote { val note = getOrCreateAddressableNoteInternal(key) @@ -448,16 +452,15 @@ object LocalCache : ILocalCache { note.author = checkGetOrCreateUser(possibleAuthor) } val relayHint = key.relay - if (!relayHint.isNullOrBlank()) { - val relay = RelayBriefInfoCache.get(RelayUrlFormatter.normalize(relayHint)) - note.addRelay(relay) + if (relayHint != null) { + note.addRelay(relayHint) } return note } fun consume( event: MetadataEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { // new event @@ -472,8 +475,8 @@ object LocalCache : ILocalCache { oldUser.updateUserInfo(newUserMetadata, event) if (relay != null) { oldUser.addRelayBeingUsed(relay, event.createdAt) - if (!RelayUrlFormatter.isLocalHost(relay.url)) { - oldUser.latestMetadataRelay = relay.url + if (!relay.isLocalHost()) { + oldUser.latestMetadataRelay = relay } } @@ -486,7 +489,7 @@ object LocalCache : ILocalCache { fun consume( event: ContactListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val user = getOrCreateUser(event.pubKey) @@ -506,61 +509,49 @@ object LocalCache : ILocalCache { fun consume( event: BookmarkListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, - ): Boolean { - val user = getOrCreateUser(event.pubKey) - if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { - if (event.dTag() == "bookmark") { - if (wasVerified || justVerify(event)) { - user.updateBookmark(event) - return true - } - } - } - - return false - } - - fun formattedDateTime(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: TextNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo? = null, + relay: NormalizedRelayUrl? = null, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: PublicMessageEvent, + relay: NormalizedRelayUrl? = null, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TorrentEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: InteractiveStoryPrologueEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: InteractiveStorySceneEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: InteractiveStoryReadingStateEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consumeRegularEvent( event: Event, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -574,7 +565,7 @@ object LocalCache : ILocalCache { // Already processed this event. if (note.event != null) return false - if (event is BaseThreadedEvent && antiSpam.isSpam(event, relay)) { + if (event is BaseNoteEvent && antiSpam.isSpam(event, relay)) { return false } @@ -586,7 +577,7 @@ object LocalCache : ILocalCache { // Counts the replies replyTo.forEach { it.addReply(note) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } else { @@ -596,67 +587,87 @@ object LocalCache : ILocalCache { fun consume( event: PictureEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: VoiceEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: VoiceReplyEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + @Suppress("DEPRECATION") fun consume( event: TorrentCommentEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90ContentDiscoveryResponseEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90ContentDiscoveryRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90StatusEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90UserDiscoveryResponseEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90UserDiscoveryRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: GoalEvent, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitPatchEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitIssueEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + @Suppress("DEPRECATION") fun consume( event: GitReplyEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: LongTextNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -690,7 +701,7 @@ object LocalCache : ILocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, replyTo) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -701,7 +712,7 @@ object LocalCache : ILocalCache { fun consume( event: WikiNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -735,7 +746,7 @@ object LocalCache : ILocalCache { note.loadEvent(event, author, replyTo) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -744,6 +755,7 @@ object LocalCache : ILocalCache { return false } + @Suppress("DEPRECATION") fun computeReplyTo(event: Event): List = when (event) { is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } @@ -753,6 +765,8 @@ object LocalCache : ILocalCache { is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is VoiceReplyEvent -> event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } @@ -769,11 +783,15 @@ object LocalCache : ILocalCache { is BadgeAwardEvent -> event.awardDefinition().map { getOrCreateAddressableNote(it) } is PrivateDmEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is RepostEvent -> - event.boostedEventIds().mapNotNull { checkGetOrCreateNote(it) } + - event.boostedAddresses().map { getOrCreateAddressableNote(it) } + listOfNotNull( + event.boostedEventId()?.let { checkGetOrCreateNote(it) }, + event.boostedAddress()?.let { getOrCreateAddressableNote(it) }, + ) is GenericRepostEvent -> - event.boostedEventIds().mapNotNull { checkGetOrCreateNote(it) } + - event.boostedAddresses().map { getOrCreateAddressableNote(it) } + listOfNotNull( + event.boostedEventId()?.let { checkGetOrCreateNote(it) }, + event.boostedAddress()?.let { getOrCreateAddressableNote(it) }, + ) is CommunityPostApprovalEvent -> event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.approvedAddresses().map { getOrCreateAddressableNote(it) } @@ -800,18 +818,18 @@ object LocalCache : ILocalCache { event.mapTaggedEventId { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) } } - else -> emptyList() + else -> emptyList() } fun consume( event: PollNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: LiveActivitiesEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -832,17 +850,17 @@ object LocalCache : ILocalCache { if (event.createdAt > (note.createdAt() ?: 0) && (isVerified || justVerify(event))) { note.loadEvent(event, author, emptyList()) - val channel = getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } as? LiveActivitiesChannel + val channel = getOrCreateLiveChannel(note.address) if (relay != null) { - channel?.addRelay(relay) + channel.addRelay(relay) } val creator = event.host()?.let { checkGetOrCreateUser(it.pubKey) } ?: author - channel?.updateChannelInfo(creator, event, event.createdAt) + channel.updateChannelInfo(creator, event) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -852,139 +870,181 @@ object LocalCache : ILocalCache { fun consume( event: MuteListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: CommunityListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: GitRepositoryEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: ChannelListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BlossomServersEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: FileServersEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: PeopleListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: EphemeralChatListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: FollowListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: AdvertisedRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: ChatMessageRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: PrivateOutboxRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: HashtagListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: GeohashListEvent, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: SearchRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: BlockedRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: TrustedRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: ProxyRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: IndexerRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: BroadcastRelayListEvent, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CommunityDefinitionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: EmojiPackSelectionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: EmojiPackEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: ClassifiedsEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: PinListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: RelaySetEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: AudioTrackEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: VideoVerticalEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: VideoHorizontalEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: StatusEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -1008,7 +1068,7 @@ object LocalCache : ILocalCache { author.flowSet?.statuses?.invalidateData() - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1018,13 +1078,13 @@ object LocalCache : ILocalCache { fun consume( event: RelationshipStatusEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: OtsEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -1039,7 +1099,7 @@ object LocalCache : ILocalCache { version.flowSet?.ots?.invalidateData() } - refreshObservers(version) + refreshNewNoteObservers(version) return true } @@ -1048,61 +1108,61 @@ object LocalCache : ILocalCache { fun consume( event: BadgeDefinitionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BadgeProfilesEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BadgeAwardEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: NNSEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: AppDefinitionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarDateSlotEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarTimeSlotEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarRSVPEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consumeBaseReplaceable( event: BaseAddressableEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -1129,7 +1189,7 @@ object LocalCache : ILocalCache { if (event.createdAt > (replaceableNote.createdAt() ?: 0) && (isVerified || justVerify(event))) { replaceableNote.loadEvent(event, author, computeReplyTo(event)) - refreshObservers(replaceableNote) + refreshNewNoteObservers(replaceableNote) return true } else { @@ -1139,19 +1199,25 @@ object LocalCache : ILocalCache { fun consume( event: AppRecommendationEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: AppSpecificDataEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: PrivateDmEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: DeletionEvent, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1166,100 +1232,78 @@ object LocalCache : ILocalCache { if (note.event != null) return false if (wasVerified || justVerify(event)) { - val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } + note.loadEvent(event, author, emptyList()) - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + if (deletionIndex.add(event, wasVerified)) { + event + .deleteEvents() + .mapNotNull { getNoteIfExists(it) } + .forEach { deleteNote -> + val deleteNoteEvent = deleteNote.event + if (deleteNoteEvent is AddressableEvent) { + val addressableNote = getAddressableNoteIfExists(deleteNoteEvent.addressTag()) + if (addressableNote?.author?.pubkeyHex == event.pubKey && (addressableNote.createdAt() ?: 0) <= event.createdAt) { + // Counts the replies + deleteNote(addressableNote) - val repliesTo = computeReplyTo(event) + addressables.remove(addressableNote.address) + } + } - note.loadEvent(event, author, repliesTo) - - if (recipient != null) { - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - - refreshObservers(note) - - return true - } - - return false - } - - fun consume( - event: DeletionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, - wasVerified: Boolean, - ): Boolean { - if (deletionIndex.add(event, wasVerified)) { - var deletedAtLeastOne = false - - event - .deleteEvents() - .mapNotNull { getNoteIfExists(it) } - .forEach { deleteNote -> - val deleteNoteEvent = deleteNote.event - if (deleteNoteEvent is AddressableEvent) { - val addressableNote = getAddressableNoteIfExists(deleteNoteEvent.addressTag()) - if (addressableNote?.author?.pubkeyHex == event.pubKey && (addressableNote.createdAt() ?: 0) <= event.createdAt) { - // Counts the replies - deleteNote(addressableNote) - - addressables.remove(addressableNote.idHex) - - deletedAtLeastOne = true + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey) { + // reverts the add + deleteNote(deleteNote) } } - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey) { - // reverts the add - deleteNote(deleteNote) + val addressList = event.deleteAddressIds() + val addressSet = addressList.toSet() - deletedAtLeastOne = true + addressList + .mapNotNull { getAddressableNoteIfExists(it) } + .forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey && (deleteNote.createdAt() ?: 0) <= event.createdAt) { + // Counts the replies + deleteNote(deleteNote) + + addressables.remove(deleteNote.address) + } } - } - val addressList = event.deleteAddressIds() - val addressSet = addressList.toSet() - - addressList - .mapNotNull { getAddressableNoteIfExists(it) } - .forEach { deleteNote -> - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey && (deleteNote.createdAt() ?: 0) <= event.createdAt) { - // Counts the replies - deleteNote(deleteNote) - - addressables.remove(deleteNote.idHex) - - deletedAtLeastOne = true - } - } - - notes.forEach { key, note -> - val noteEvent = note.event - if (noteEvent is AddressableEvent && noteEvent.addressTag() in addressSet) { - if (noteEvent.pubKey == event.pubKey && noteEvent.createdAt <= event.createdAt) { - deleteNote(note) - deletedAtLeastOne = true + notes.forEach { key, note -> + val noteEvent = note.event + if (noteEvent is AddressableEvent && noteEvent.addressTag() in addressSet) { + if (noteEvent.pubKey == event.pubKey && noteEvent.createdAt <= event.createdAt) { + deleteNote(note) + } } } } - if (deletedAtLeastOne) { - val note = Note(event.id) - note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList()) - refreshObservers(note) - } + refreshNewNoteObservers(note) return true + } else { + return false } - - return false } + fun getAnyChannel(note: Note): Channel? = note.event?.let { getAnyChannel(it) } + + fun getAnyChannel(noteEvent: Event): Channel? = + when (noteEvent) { + is ChannelCreateEvent -> getPublicChatChannelIfExists(noteEvent.id) + is ChannelMetadataEvent -> noteEvent.channelId()?.let { getPublicChatChannelIfExists(it) } + is ChannelMessageEvent -> noteEvent.channelId()?.let { getPublicChatChannelIfExists(it) } + is LiveActivitiesChatMessageEvent -> noteEvent.activityAddress()?.let { getLiveActivityChannelIfExists(it) } + is LiveActivitiesEvent -> getLiveActivityChannelIfExists(noteEvent.address()) + is EphemeralChatEvent -> noteEvent.roomId()?.let { getEphemeralChatChannelIfExists(it) } + else -> null + } + + @Suppress("DEPRECATION") private fun deleteNote(deleteNote: Note) { val deletedEvent = deleteNote.event @@ -1274,44 +1318,18 @@ object LocalCache : ILocalCache { // Counts the replies deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeZapPayment(deleteNote) - masterNote.removeReport(deleteNote) + masterNote.removeNote(deleteNote) } - deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) } + deleteNote.inGatherers?.forEach { it.removeNote(deleteNote) } - (deletedEvent as? LiveActivitiesChatMessageEvent)?.activity()?.let { - getChannelIfExists(it.toTag())?.removeNote(deleteNote) - } + getAnyChannel(deleteNote)?.removeNote(deleteNote) (deletedEvent as? TorrentCommentEvent)?.torrentIds()?.let { getNoteIfExists(it)?.removeReply(deleteNote) } - if (deletedEvent is PrivateDmEvent) { - val author = deleteNote.author - val recipient = - deletedEvent.verifiedRecipientPubKey()?.let { - checkGetOrCreateUser(it) - } - - if (recipient != null && author != null) { - author.removeMessage(recipient, deleteNote) - recipient.removeMessage(author, deleteNote) - } - } - - if (deletedEvent is DraftEvent) { - deletedEvent.allCache().forEach { - it?.let { - deindexDraftAsRealEvent(deleteNote, it) - } - } - } + notes.remove(deleteNote.idHex) if (deletedEvent is WrappedEvent) { deleteWraps(deletedEvent) @@ -1319,27 +1337,28 @@ object LocalCache : ILocalCache { deleteNote.clearFlow() - notes.remove(deleteNote.idHex) + refreshDeletedNoteObservers(deleteNote) } fun deleteWraps(event: WrappedEvent) { - event.host?.let { + event.host?.let { hostStub -> // seal - getNoteIfExists(it.id)?.let { - val noteEvent = it.event + getNoteIfExists(hostStub.id)?.let { hostNote -> + val noteEvent = hostNote.event if (noteEvent is WrappedEvent) { deleteWraps(noteEvent) } - it.clearFlow() + hostNote.clearFlow() + refreshDeletedNoteObservers(hostNote) } - notes.remove(it.id) + notes.remove(hostStub.id) } } fun consume( event: RepostEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1357,10 +1376,10 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addBoost(note) } event.containedPost()?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1369,7 +1388,7 @@ object LocalCache : ILocalCache { fun consume( event: GenericRepostEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1387,10 +1406,10 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addBoost(note) } event.containedPost()?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1400,7 +1419,7 @@ object LocalCache : ILocalCache { fun consume( event: CommunityPostApprovalEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1422,10 +1441,10 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addBoost(note) } event.containedPost()?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1435,7 +1454,7 @@ object LocalCache : ILocalCache { fun consume( event: ReactionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1451,7 +1470,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addReaction(note) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1461,7 +1480,7 @@ object LocalCache : ILocalCache { fun consume( event: ReportEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1495,7 +1514,7 @@ object LocalCache : ILocalCache { } } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1505,11 +1524,11 @@ object LocalCache : ILocalCache { fun consume( event: ChannelCreateEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } + val oldChannel = getOrCreatePublicChatChannel(event.id) val author = getOrCreateUser(event.pubKey) val note = getOrCreateNote(event.id) @@ -1518,7 +1537,7 @@ object LocalCache : ILocalCache { oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) true } else { wasVerified @@ -1529,7 +1548,7 @@ object LocalCache : ILocalCache { } if (oldChannel.creator == null || oldChannel.creator == author) { - if (oldChannel is PublicChatChannel && (isVerified || justVerify(event))) { + if (isVerified || justVerify(event)) { oldChannel.updateChannelInfo(author, event) } } @@ -1539,23 +1558,23 @@ object LocalCache : ILocalCache { fun consume( event: ChannelMetadataEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val channelId = event.channelId() if (channelId.isNullOrBlank()) return false // new event - val oldChannel = checkGetOrCreateChannel(channelId) ?: return false + val oldChannel = checkGetOrCreatePublicChatChannel(channelId) ?: return false val author = getOrCreateUser(event.pubKey) val isVerified = if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel is PublicChatChannel && (wasVerified || justVerify(event))) { + if (wasVerified || justVerify(event)) { oldChannel.updateChannelInfo(author, event) true } else { - wasVerified + false } } else { wasVerified @@ -1566,7 +1585,7 @@ object LocalCache : ILocalCache { oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) } return isVerified @@ -1574,153 +1593,86 @@ object LocalCache : ILocalCache { fun consume( event: ChannelMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { - val channelId = event.channelId() + val channelId = event.channelId() ?: return false - if (channelId.isNullOrBlank()) return false + val new = consumeRegularEvent(event, relay, wasVerified) - val channel = checkGetOrCreateChannel(channelId) ?: return false + if (new) { + val channel = checkGetOrCreatePublicChatChannel(channelId) + if (channel == null) { + Log.w("LocalCache", "Unable to create public chat channel for event ${event.toJson()}") + return false + } - val note = getOrCreateNote(event.id) - channel.addNote(note, relay) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + val note = getOrCreateNote(event.id) + channel.addNote(note, relay) } - // Already processed this event. - if (note.event != null) return false - - if (antiSpam.isSpam(event, relay)) { - return false - } - - if (wasVerified || justVerify(event)) { - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content} - // ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } - - return true + return new } fun consume( event: EphemeralChatEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { - val relayUrl = event.relay().ifBlank { return false } + val roomId = event.roomId() ?: return false - val channelId = RoomId(event.room(), relayUrl) + val new = consumeRegularEvent(event, relay, wasVerified) - val channel = checkGetOrCreateChannel(channelId) ?: return false - - val note = getOrCreateNote(event.id) - channel.addNote(note, relay) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + if (new) { + val note = getOrCreateNote(event.id) + val channel = getOrCreateEphemeralChannel(roomId) + channel.addNote(note, relay) } - // Already processed this event. - if (note.event != null) return false - - if (antiSpam.isSpam(event, relay)) { - return false - } - - if (wasVerified || justVerify(event)) { - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - - return true - } - - return false + return new } - fun consume( - event: CommentEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - fun consume( event: LiveActivitiesChatMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val activityAddress = event.activityAddress() ?: return false - val channel = getOrCreateChannel(activityAddress.toValue()) { LiveActivitiesChannel(activityAddress) } + val new = consumeRegularEvent(event, relay, wasVerified) - val note = getOrCreateNote(event.id) - channel.addNote(note, relay) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + if (new) { + val channel = getOrCreateLiveChannel(activityAddress) + val note = getOrCreateNote(event.id) + channel.addNote(note, relay) } - // Already processed this event. - if (note.event != null) return false - - if (antiSpam.isSpam(event, relay)) { - return false - } - - if (wasVerified || justVerify(event)) { - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - - return true - } - - return false + return new } + fun consume( + event: CommentEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean = false @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelMuteUserEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean = false fun consume( event: LnZapEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1729,11 +1681,10 @@ object LocalCache : ILocalCache { if (wasVerified || justVerify(event)) { val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } - if (existingZapRequest == null || existingZapRequest.event == null) { // tries to add it event.zapRequest?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } } @@ -1753,7 +1704,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addZap(zapRequest, note) } mentions.forEach { it.addZap(zapRequest, note) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1763,7 +1714,7 @@ object LocalCache : ILocalCache { fun consume( event: LnZapRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1781,7 +1732,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addZap(note, null) } mentions.forEach { it.addZap(note, null) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1791,37 +1742,37 @@ object LocalCache : ILocalCache { fun consume( event: AudioHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: ProfileGalleryEntryEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileStorageHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FhirResourceEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TextNoteModificationEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1846,7 +1797,7 @@ object LocalCache : ILocalCache { } } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1856,13 +1807,13 @@ object LocalCache : ILocalCache { fun consume( event: HighlightEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileStorageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1884,7 +1835,7 @@ object LocalCache : ILocalCache { stream.close() Log.i( "FileStorageEvent", - "NIP95 File received from ${relay?.url} and saved to disk as $file", + "NIP95 File received from $relay and saved to disk as $file", ) true } else { @@ -1905,7 +1856,7 @@ object LocalCache : ILocalCache { note.loadEvent(eventNoData, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1915,156 +1866,31 @@ object LocalCache : ILocalCache { private fun consume( event: ChatMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, - ): Boolean { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return false - - if (wasVerified || justVerify(event)) { - val recipientsHex = event.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = computeReplyTo(event) - - note.loadEvent(event, author, repliesTo) - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } - } - - refreshObservers(note) - - return true - } - - return false - } + ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: ChatMessageEncryptedFileHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, - ): Boolean { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return false - - if (wasVerified || justVerify(event)) { - val recipientsHex = event.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = computeReplyTo(event) - - note.loadEvent(event, author, repliesTo) - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } - } - - refreshObservers(note) - - return true - } - - return false - } + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: SealedRumorEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, - ): Boolean { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return false - - if (wasVerified || justVerify(event)) { - note.loadEvent(event, author, emptyList()) - refreshObservers(note) - return true - } - - return false - } + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GiftWrapEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, - ): Boolean { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return false - - if (wasVerified || justVerify(event)) { - note.loadEvent(event, author, emptyList()) - refreshObservers(note) - return true - } - - return false - } + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: LnZapPaymentRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { // Does nothing without a response callback. @@ -2075,7 +1901,8 @@ object LocalCache : ILocalCache { event: LnZapPaymentRequestEvent, zappedNote: Note?, wasVerified: Boolean, - onResponse: (LnZapPaymentResponseEvent) -> Unit, + relay: NormalizedRelayUrl?, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -2086,11 +1913,15 @@ object LocalCache : ILocalCache { if (wasVerified || justVerify(event)) { note.loadEvent(event, author, emptyList()) + relay?.let { + note.addRelay(relay) + } + zappedNote?.addZapPayment(note, null) awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -2100,7 +1931,7 @@ object LocalCache : ILocalCache { fun consume( event: LnZapPaymentResponseEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val requestId = event.requestId() @@ -2121,7 +1952,9 @@ object LocalCache : ILocalCache { requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } - responseCallback(event) + GlobalScope.launch(Dispatchers.Default) { + responseCallback(event) + } return true } @@ -2153,7 +1986,7 @@ object LocalCache : ILocalCache { user.pubkeyHex.startsWith(username, true) || user.pubkeyNpub().startsWith(username, true) ) && - (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase))) + (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) } return finds.sortedWith( @@ -2183,7 +2016,7 @@ object LocalCache : ILocalCache { fun findNotesStartingWith( text: String, - forAccount: Account, + hiddenUsers: HiddenUsersState, ): List { checkNotInMainThread() @@ -2206,15 +2039,11 @@ object LocalCache : ILocalCache { if (note.event?.tags?.tagValueContains(text, true) == true || note.idHex.startsWith(text, true) ) { - if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { - return@filter true - } else { - return@filter false - } + return@filter !note.isHiddenFor(hiddenUsers.flow.value) } if (note.event?.isContentEncoded() == false) { - if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!note.isHiddenFor(hiddenUsers.flow.value)) { return@filter note.event?.content?.contains(text, true) ?: false } else { return@filter false @@ -2231,15 +2060,11 @@ object LocalCache : ILocalCache { if (addressable.event?.tags?.tagValueContains(text, true) == true || addressable.idHex.startsWith(text, true) ) { - if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) { - return@filter true - } else { - return@filter false - } + return@filter !addressable.isHiddenFor(hiddenUsers.flow.value) } if (addressable.event?.isContentEncoded() == false) { - if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!addressable.isHiddenFor(hiddenUsers.flow.value)) { return@filter addressable.event?.content?.contains(text, true) ?: false } else { return@filter false @@ -2250,20 +2075,44 @@ object LocalCache : ILocalCache { } } - fun findChannelsStartingWith(text: String): List { - checkNotInMainThread() - + fun findPublicChatChannelsStartingWith(text: String): List { if (text.isBlank()) return emptyList() val key = decodeEventIdAsHexOrNull(text) - if (key != null && getChannelIfExists(key) != null) { - return listOfNotNull(getChannelIfExists(key)) + if (key != null) { + getPublicChatChannelIfExists(key)?.let { + return listOf(it) + } } - return channels.filter { _, channel -> - channel.anyNameStartsWith(text) || - channel.idHex.startsWith(text, true) || - channel.idNote().startsWith(text, true) + return publicChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findEphemeralChatChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + return ephemeralChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findLiveActivityChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + try { + val parsed = Nip19Parser.uriToRoute(text)?.entity + if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) { + return listOf(getOrCreateLiveChannel(parsed.address())) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + return liveChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) } } @@ -2286,7 +2135,7 @@ object LocalCache : ILocalCache { suspend fun findEarliestOtsForNote( note: Note, - resolverBuilder: () -> OtsResolver, + resolverBuilder: OtsResolverBuilder, ): Long? { checkNotInMainThread() @@ -2314,7 +2163,7 @@ object LocalCache : ILocalCache { suspend fun findLatestModificationForNote(note: Note): List { checkNotInMainThread() - val originalAuthor = note.author?.pubkeyHex ?: return emptyList() + val noteAuthor = note.author ?: return emptyList() modificationCache[note.idHex]?.let { return it @@ -2327,7 +2176,7 @@ object LocalCache : ILocalCache { .filter { _, item -> val noteEvent = item.event - noteEvent is TextNoteModificationEvent && note.author == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) + noteEvent is TextNoteModificationEvent && noteAuthor == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) modificationCache.put(note.idHex, newNotes) @@ -2335,6 +2184,12 @@ object LocalCache : ILocalCache { return newNotes } + fun cleanMemory() { + notes.cleanUp() + addressables.cleanUp() + users.cleanUp() + } + fun cleanObservers() { notes.forEach { _, it -> it.clearFlow() } addressables.forEach { _, it -> it.clearFlow() } @@ -2357,60 +2212,86 @@ object LocalCache : ILocalCache { } } + fun pruneHiddenMessagesChannel( + channel: Channel, + account: Account, + ) { + val toBeRemoved = channel.pruneHiddenMessages(account) + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { + println( + "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + fun pruneHiddenMessages(account: Account) { - channels.forEach { _, channel -> - val toBeRemoved = channel.pruneHiddenMessages(account) + ephemeralChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } - val childrenToBeRemoved = mutableListOf() + liveChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } - toBeRemoved.forEach { - removeFromCache(it) + publicChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + } - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } + fun pruneOldMessagesChannel(channel: Channel) { + val toBeRemoved = channel.pruneOldMessages() - removeFromCache(childrenToBeRemoved) + val childrenToBeRemoved = mutableListOf() - if (toBeRemoved.size > 100 || channel.notes.size() > 100) { - println( - "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", - ) - } + 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", + ) } } fun pruneOldMessages() { checkNotInMainThread() - channels.forEach { _, channel -> - val toBeRemoved = channel.pruneOldMessages() - - val childrenToBeRemoved = mutableListOf() - - 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", - ) - } + ephemeralChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) } - users.forEach { _, user -> - user.privateChatrooms.values.map { - val toBeRemoved = it.pruneMessagesToTheLatestOnly() + liveChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + publicChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + chatroomList.forEach { userHex, room -> + room.rooms.map { key, chatroom -> + val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - // TODO: NEED TO TEST IF WRAPS COME BACK WHEN NEEDED BEFORE ACTIVATING - // childrenToBeRemoved.addAll(removeIfWrap(it)) + childrenToBeRemoved.addAll(removeIfWrap(it)) removeFromCache(it) childrenToBeRemoved.addAll(it.removeAllChildNotes()) @@ -2420,7 +2301,7 @@ object LocalCache : ILocalCache { if (toBeRemoved.size > 1) { println( - "PRUNE: ${toBeRemoved.size} private messages from ${user.toBestDisplayName()} to ${it.authors.joinToString(", ") { it.toBestDisplayName() }} removed. ${it.roomMessages.size} kept", + "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", ) } } @@ -2451,7 +2332,7 @@ object LocalCache : ILocalCache { val noteEvent = note.event if (noteEvent is AddressableEvent) { noteEvent.createdAt < - (addressables.get(noteEvent.aTag().toTag())?.event?.createdAt ?: 0) + (addressables.get(noteEvent.address())?.event?.createdAt ?: 0) } else { false } @@ -2460,7 +2341,7 @@ object LocalCache : ILocalCache { val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.aTag()?.toTag()?.let { tag -> addressables.get(tag) } + val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> addressables.get(tag) } if (newerVersion != null) { it.moveAllReferencesTo(newerVersion) } @@ -2515,13 +2396,11 @@ object LocalCache : ILocalCache { private fun removeFromCache(note: Note) { note.replyTo?.forEach { masterNote -> - masterNote.removeReply(note) - masterNote.removeBoost(note) - masterNote.removeReaction(note) - masterNote.removeZap(note) - masterNote.removeReport(note) + masterNote.removeNote(note) } + note.inGatherers?.forEach { it.removeNote(note) } + val noteEvent = note.event if (noteEvent is LnZapEvent) { @@ -2546,6 +2425,8 @@ object LocalCache : ILocalCache { note.clearFlow() notes.remove(note.idHex) + + refreshDeletedNoteObservers(note) } fun removeFromCache(nextToBeRemoved: List) { @@ -2578,7 +2459,7 @@ object LocalCache : ILocalCache { val childrenToBeRemoved = mutableListOf() val toBeRemoved = - account.flowHiddenUsers.value.hiddenUsers + account.hiddenUsers.flow.value.hiddenUsers .map { userHex -> (notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() }.flatten() @@ -2613,7 +2494,7 @@ object LocalCache : ILocalCache { override fun markAsSeen( eventId: String, - relay: RelayBriefInfoCache.RelayBriefInfo, + relay: NormalizedRelayUrl, ) { val note = getNoteIfExists(eventId) @@ -2629,11 +2510,14 @@ object LocalCache : ILocalCache { // Observers line up here. val live: LocalCacheFlow = LocalCacheFlow() - private fun refreshObservers(newNote: Note) { + private fun refreshNewNoteObservers(newNote: Note) { val event = newNote.event as Event updateObservables(event) - onNewEvents.forEach { it(newNote) } - live.invalidateData(newNote) + live.newNote(newNote) + } + + private fun refreshDeletedNoteObservers(newNote: Note) { + live.removedNote(newNote) } fun justVerify(event: Event): Boolean { @@ -2644,7 +2528,7 @@ object LocalCache : ILocalCache { event.checkSignature() } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("Event Verification Failed", "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}: ${event.toJson()}") + Log.w("Event Verification Failed", "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}") } false } else { @@ -2654,175 +2538,78 @@ object LocalCache : ILocalCache { fun consume( event: DraftEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { if (!event.isDeleted()) { if (consumeBaseReplaceable(event, relay, wasVerified)) { return true } + } else { + // passes to the AccountViewModel for further delete. + val note = Note(event.id) + note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList()) + relay?.let { note.addRelay(it) } + refreshNewNoteObservers(note) } return false } - fun indexDraftAsRealEvent( - draftWrap: DraftEvent, - draft: Event, - ) { - val note = getOrCreateAddressableNote(draftWrap.address()) - val author = getOrCreateUser(draftWrap.pubKey) - - when (draft) { - is PrivateDmEvent -> { - draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient -> - author.addMessage(recipient, note) - recipient.addMessage(author, note) + fun consume(nip19: Entity) { + when (nip19) { + is NSec -> getOrCreateUser(nip19.toPubKeyHex()) + is NPub -> getOrCreateUser(nip19.hex) + is NProfile -> { + nip19.relay.forEach { relayHint -> + relayHints.addKey(nip19.hex, relayHint) } + getOrCreateUser(nip19.hex) } - is ChatMessageEvent -> { - val recipientsHex = draft.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } + is NNote -> { + getOrCreateNote(nip19.hex) + } + is NEvent -> { + nip19.relay.forEach { relayHint -> + relayHints.addEvent(nip19.hex, relayHint) } + getOrCreateNote(nip19.hex) } - is ChatMessageEncryptedFileHeaderEvent -> { - val recipientsHex = draft.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } + is NEmbed -> { + justConsume(nip19.event, null, false) + } + is NRelay -> {} + is NAddress -> { + val aTag = nip19.aTag() + nip19.relay.forEach { relayHint -> + relayHints.addAddress(aTag, relayHint) } + getOrCreateAddressableNote(nip19.address()) } - is EphemeralChatEvent -> { - checkGetOrCreateChannel(draft.roomId().toKey())?.addNote(note, null) - } - is ChannelMessageEvent -> { - draft.channelId()?.let { channelId -> - checkGetOrCreateChannel(channelId)?.addNote(note, null) - } - } - is LiveActivitiesChatMessageEvent -> { - draft.activityAddress()?.let { channelId -> - checkGetOrCreateChannel(channelId.toValue())?.addNote(note, null) - } - } - is TextNoteEvent -> { - val replyTo = computeReplyTo(draft) - val author = getOrCreateUser(draftWrap.pubKey) - note.loadEvent(draftWrap, author, replyTo) - replyTo.forEach { it.addReply(note) } - } + else -> { } } } - fun deindexDraftAsRealEvent( - draftWrap: Note, - draft: Event, - ) { - val author = draftWrap.author ?: return - - when (draft) { - is PrivateDmEvent -> { - draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient -> - author.removeMessage(recipient, draftWrap) - recipient.removeMessage(author, draftWrap) - } - } - is ChatMessageEvent -> { - val recipientsHex = draft.recipientsPubKey().plus(author.pubkeyHex).toSet() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.removeMessage(authorGroup, draftWrap) - } - } - } - is ChatMessageEncryptedFileHeaderEvent -> { - val recipientsHex = draft.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.removeMessage(authorGroup, draftWrap) - } - } - } - is ChannelMessageEvent -> { - draft.channelId()?.let { channelId -> - checkGetOrCreateChannel(channelId)?.removeNote(draftWrap) - } - } - is EphemeralChatEvent -> { - checkGetOrCreateChannel(draft.roomId())?.removeNote(draftWrap) - } - is TextNoteEvent -> { - val replyTo = computeReplyTo(draft) - replyTo.forEach { it.removeReply(draftWrap) } - } - } - } - - fun justConsumeMyOwnEvent(event: Event) = justConsumeInner(event, null, true) + fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true) fun justConsume( event: Event, - relay: Relay?, + relay: IRelayClient?, wasVerified: Boolean, ): Boolean { if (deletionIndex.hasBeenDeleted(event)) { // update relay with deletion event from another. if (relay != null) { - deletionIndex.hasBeenDeletedBy(event)?.let { - Log.d("LocalCache", "Updating ${relay.url} with a Deletion Event ${it.toJson()} because of ${event.toJson()}") - relay.send(it) + deletionIndex.hasBeenDeletedBy(event)?.let { deletionEvent -> + getNoteIfExists(deletionEvent.id)?.let { note -> + if (!note.hasRelay(relay.url)) { + if (isDebug) { + Log.d("LocalCache", "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}") + } + relay.send(deletionEvent) + note.addRelay(relay.url) + } + } } } return false @@ -2830,22 +2617,100 @@ object LocalCache : ILocalCache { if (event is AddressableEvent && relay != null) { // updates relay with a new event. - getAddressableNoteIfExists(event.addressTag())?.let { note -> + getAddressableNoteIfExists(event.address())?.let { note -> note.event?.let { existingEvent -> - if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.brief)) { - Log.d("LocalCache", "Updating ${relay.url} with a new version of ${event.toJson()} to ${existingEvent.toJson()}") + if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.url) && !deletionIndex.hasBeenDeleted(event)) { + if (isDebug) { + Log.d("LocalCache", "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}") + } + relay.send(existingEvent) + // only send once. + note.addRelay(relay.url) } } } } - return justConsumeInner(event, relay?.brief, wasVerified) + return justConsumeAndUpdateIndexes(event, relay?.url, wasVerified) } - fun justConsumeInner( + private fun justConsumeAndUpdateIndexes( event: Event, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val wasNew = justConsumeInnerInner(event, relay, wasVerified) + + if (wasNew) { + updateHintIndexes(event) + } + + if (relay != null) { + // uses the internal event to avoid reprocessing cached items. + val note = + if (event is AddressableEvent) { + getAddressableNoteIfExists(event.address()) + } else { + getNoteIfExists(event.id) + } + + note?.event?.let { consumedEvent -> + addIncomingRelayAsHintToAllRelatedEvents(consumedEvent, relay) + } + } + + return wasNew + } + + fun addIncomingRelayAsHintToAllRelatedEvents( + event: Event, + relay: NormalizedRelayUrl, + ) { + relayHints.addEvent(event.id, relay) + if (event is AddressableEvent) { + relayHints.addAddress(event.addressTag(), relay) + } + + if (event is EventHintProvider) { + event.linkedEventIds().forEach { + relayHints.addEvent(it, relay) + } + } + if (event is AddressHintProvider) { + event.linkedAddressIds().forEach { + relayHints.addAddress(it, relay) + } + } + if (event is PubKeyHintProvider) { + event.linkedPubKeys().forEach { + relayHints.addKey(it, relay) + } + } + } + + fun updateHintIndexes(event: Event) { + if (event is EventHintProvider) { + event.eventHints().forEach { + relayHints.addEvent(it.eventId, it.relay) + } + } + if (event is AddressHintProvider) { + event.addressHints().forEach { + relayHints.addAddress(it.addressId, it.relay) + } + } + if (event is PubKeyHintProvider) { + event.pubKeyHints().forEach { + relayHints.addKey(it.pubkey, it.relay) + } + } + } + + @Suppress("DEPRECATION") + private fun justConsumeInnerInner( + event: Event, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean = try { @@ -2859,7 +2724,9 @@ object LocalCache : ILocalCache { is BadgeAwardEvent -> consume(event, relay, wasVerified) is BadgeDefinitionEvent -> consume(event, relay, wasVerified) is BadgeProfilesEvent -> consume(event, relay, wasVerified) + is BlockedRelayListEvent -> consume(event, relay, wasVerified) is BlossomServersEvent -> consume(event, relay, wasVerified) + is BroadcastRelayListEvent -> consume(event, relay, wasVerified) is BookmarkListEvent -> consume(event, relay, wasVerified) is CalendarEvent -> consume(event, relay, wasVerified) is CalendarDateSlotEvent -> consume(event, relay, wasVerified) @@ -2894,12 +2761,16 @@ object LocalCache : ILocalCache { is FileStorageEvent -> consume(event, relay, wasVerified) is FileStorageHeaderEvent -> consume(event, relay, wasVerified) is FollowListEvent -> consume(event, relay, wasVerified) + is GeohashListEvent -> consume(event, relay, wasVerified) + is GoalEvent -> consume(event, relay, wasVerified) is GiftWrapEvent -> consume(event, relay, wasVerified) is GitIssueEvent -> consume(event, relay, wasVerified) is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is HashtagListEvent -> consume(event, relay, wasVerified) is HighlightEvent -> consume(event, relay, wasVerified) + is IndexerRelayListEvent -> consume(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified) is InteractiveStorySceneEvent -> consume(event, relay, wasVerified) is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified) @@ -2922,7 +2793,9 @@ object LocalCache : ILocalCache { is PictureEvent -> consume(event, relay, wasVerified) is PrivateDmEvent -> consume(event, relay, wasVerified) is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified) + is ProxyRelayListEvent -> consume(event, relay, wasVerified) is PinListEvent -> consume(event, relay, wasVerified) + is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) is PollNoteEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) @@ -2937,23 +2810,26 @@ object LocalCache : ILocalCache { is TextNoteModificationEvent -> consume(event, relay, wasVerified) is TorrentEvent -> consume(event, relay, wasVerified) is TorrentCommentEvent -> consume(event, relay, wasVerified) + is TrustedRelayListEvent -> consume(event, relay, wasVerified) is VideoHorizontalEvent -> consume(event, relay, wasVerified) is VideoVerticalEvent -> consume(event, relay, wasVerified) + is VoiceEvent -> consume(event, relay, wasVerified) + is VoiceReplyEvent -> consume(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) else -> { - Log.w("Event Not Supported", event.toJson()) + Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}") false } } } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() + Log.w("LocalCache", "Cannot consume ${event.kind}", e) false } fun hasConsumed(notificationEvent: Event): Boolean = if (notificationEvent is AddressableEvent) { - val note = addressables.get(notificationEvent.addressTag()) + val note = addressables.get(notificationEvent.address()) val noteEvent = note?.event noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt } else { @@ -2984,15 +2860,27 @@ object LocalCache : ILocalCache { @Stable class LocalCacheFlow { - private val _newEventBundles = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) + private val _newEventBundles = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view + private val _deletedEventBundles = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) + val deletedEventBundles = _deletedEventBundles.asSharedFlow() // read-only public view + // Refreshes observers in batches. private val bundler = BundledInsert(1000, Dispatchers.Default) - fun invalidateData(newNote: Note) { + // Refreshes observers in batches. + private val bundler2 = BundledInsert(1000, Dispatchers.Default) + + fun newNote(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } } + + fun removedNote(newNote: Note) { + bundler2.invalidateList(newNote) { bundledNewNotes -> + _deletedEventBundles.emit(bundledNewNotes) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt index dcf2401ef8..b4b199f9dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model import android.util.LruCache interface MutableMediaAspectRatioCache { - fun get(url: String): Float + fun get(url: String): Float? fun add( url: String, @@ -35,7 +35,7 @@ interface MutableMediaAspectRatioCache { object MediaAspectRatioCache : MutableMediaAspectRatioCache { val mediaAspectRatioCacheByUrl = LruCache(1000) - override fun get(url: String) = mediaAspectRatioCacheByUrl.get(url) + override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url) override fun add( url: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 97b7c0c7ba..03158d3eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -22,22 +22,20 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.launchAndWaitAll +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState 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.toShortenHex -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward -import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -52,16 +50,14 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent 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.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -71,16 +67,19 @@ import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.containsAny +import com.vitorpamplona.quartz.utils.launchAndWaitAll import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import java.math.BigDecimal -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume + +interface NotesGatherer { + fun removeNote(note: Note) +} @Stable class AddressableNote( @@ -90,7 +89,7 @@ class AddressableNote( override fun toNEvent() = toNAddr() - override fun idDisplayNote() = idNote().toShortenHex() + override fun idDisplayNote() = idNote().toShortDisplay() override fun address() = address @@ -129,13 +128,34 @@ class AddressableNote( @Stable open class Note( val idHex: String, -) { +) : NotesGatherer { // These fields are only available after the Text Note event is received. // They are immutable after that. var event: Event? = null var author: User? = null var replyTo: List? = null + var inGatherers: List? = null + + fun inGatherers() = inGatherers ?: listOf().also { inGatherers = it } + + fun addGatherer(gatherer: NotesGatherer) { + inGatherers = inGatherers() + gatherer + } + + fun removeGatherer(gatherer: NotesGatherer) { + inGatherers = inGatherers() - gatherer + } + + override fun removeNote(note: Note) { + removeReply(note) + removeBoost(note) + removeReaction(note) + removeZap(note) + removeZapPayment(note) + removeReport(note) + } + // These fields are updated every time an event related to this note is received. var replies = listOf() private set @@ -157,11 +177,9 @@ open class Note( var zapPayments = mapOf() private set - var relays = listOf() + var relays = listOf() private set - fun id() = Hex.decode(idHex) - open fun idNote() = toNEvent() open fun toNEvent(): String { @@ -183,14 +201,26 @@ open class Note( } } - fun relayHintUrl(): String? { + fun relayUrls(): List { + val authorRelay = author?.relayHints()?.ifEmpty { null } + + return authorRelay ?: relays + } + + fun relayUrlsForReactions(): List { + val authorRelay = author?.inboxRelays()?.ifEmpty { null } + + return authorRelay ?: relays + } + + fun relayHintUrl(): NormalizedRelayUrl? { val authorRelay = author?.latestMetadataRelay return if (relays.isNotEmpty()) { - if (authorRelay != null && relays.any { it.url == authorRelay }) { + if (authorRelay != null && relays.any { it == authorRelay }) { authorRelay } else { - relays.firstOrNull()?.url + relays.firstOrNull() } } else { null @@ -199,26 +229,7 @@ open class Note( fun toNostrUri(): String = "nostr:${toNEvent()}" - open fun idDisplayNote() = idNote().toShortenHex() - - fun channelHex(): HexKey? = - if ( - event is ChannelMessageEvent || - event is ChannelMetadataEvent || - event is ChannelCreateEvent || - event is LiveActivitiesChatMessageEvent || - event is LiveActivitiesEvent || - event is EphemeralChatEvent - ) { - (event as? ChannelMessageEvent)?.channelId() - ?: (event as? ChannelMetadataEvent)?.channelId() - ?: (event as? ChannelCreateEvent)?.id - ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.aTag()?.toTag() - ?: (event as? EphemeralChatEvent)?.roomId()?.toKey() - } else { - null - } + open fun idDisplayNote() = idNote().toShortDisplay() open fun address(): Address? = null @@ -293,7 +304,7 @@ open class Note( zaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal.ZERO - relays = listOf() + relays = listOf() if (repliesChanged) flowSet?.replies?.invalidateData() if (reactionsChanged) flowSet?.reactions?.invalidateData() @@ -445,17 +456,17 @@ open class Note( } @Synchronized - fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { - if (briefInfo !in relays) { - relays = relays + briefInfo + fun addRelaySync(relay: NormalizedRelayUrl) { + if (relay !in relays) { + relays = relays + relay } } - fun hasRelay(relay: RelayBriefInfoCache.RelayBriefInfo) = relay !in relays + fun hasRelay(relay: NormalizedRelayUrl) = relay in relays - fun addRelay(brief: RelayBriefInfoCache.RelayBriefInfo) { - if (brief !in relays) { - addRelaySync(brief) + fun addRelay(relay: NormalizedRelayUrl) { + if (relay !in relays) { + addRelaySync(relay) flowSet?.relays?.invalidateData() } } @@ -475,21 +486,13 @@ open class Note( val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent if (zapResponseEvent != null) { - val result = - tryAndWait { continuation -> - account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> - if ( - response is PayInvoiceSuccessResponse && - account.isNIP47Author(zapResponseEvent.requestAuthor()) - ) { - continuation.resume(true) - } - } - } + account.nip47SignerState.decryptResponse(zapResponseEvent)?.let { response -> + val result = response is PayInvoiceSuccessResponse && account.nip47SignerState.isNIP47Author(zapResponseEvent.requestAuthor()) - if (!hasSentOne && result == true) { - hasSentOne = true - onWasZappedByAuthor() + if (!hasSentOne && result == true) { + hasSentOne = true + onWasZappedByAuthor() + } } } } @@ -506,6 +509,8 @@ open class Note( return } + val parallelDecrypt = mutableListOf>() + zapEvents.forEach { next -> val zapRequest = next.key.event as LnZapRequestEvent val zapEvent = next.value?.event as? LnZapEvent @@ -520,7 +525,7 @@ open class Note( // private events // if has already decrypted - val privateZap = zapRequest.cachedPrivateZap() + val privateZap = account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest) if (privateZap != null) { if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) { onWasZappedByAuthor() @@ -528,21 +533,22 @@ open class Note( } } else { if (account.isWriteable()) { - val result = - tryAndWait { continuation -> - zapRequest.decryptPrivateZap(account.signer) { - continuation.resume(it) - } - } - - if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) { - onWasZappedByAuthor() - return - } + parallelDecrypt.add(Pair(zapRequest, zapEvent)) } } } } + + val result = + anyAsync(parallelDecrypt) { pair -> + val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first) + + result?.pubKey == user.pubkeyHex && (option == null || option == pair.second?.zappedPollOption()) + } + + if (result) { + onWasZappedByAuthor() + } } suspend fun isZappedBy( @@ -608,26 +614,21 @@ open class Note( startAmount: BigDecimal, paidInvoiceSet: LinkedHashSet, zapPayments: List>, - signer: NostrSigner, - onReady: (BigDecimal) -> Unit, - ) { + signerState: NwcSignerState, + ): BigDecimal { if (zapPayments.isEmpty()) { - onReady(startAmount) - return + return startAmount } var output: BigDecimal = startAmount launchAndWaitAll(zapPayments) { next -> val result = - tryAndWait { continuation -> - processZapAmountFromResponse( - next.first, - next.second, - continuation, - signer, - ) - } + processZapAmountFromResponse( + next.first, + next.second, + signerState, + ) if (result != null && !paidInvoiceSet.contains(result.invoice)) { paidInvoiceSet.add(result.invoice) @@ -635,27 +636,25 @@ open class Note( } } - onReady(output) + return output } - private fun processZapAmountFromResponse( + private suspend fun processZapAmountFromResponse( paymentRequest: Note, paymentResponse: Note?, - continuation: Continuation, - signer: NostrSigner, - ) { + signerState: NwcSignerState, + ): InvoiceAmount? { val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent - if (nwcRequest != null && nwcResponse != null) { + return if (nwcRequest != null && nwcResponse != null) { processZapAmountFromResponse( nwcRequest, nwcResponse, - continuation, - signer, + signerState, ) } else { - continuation.resume(null) + null } } @@ -664,18 +663,19 @@ open class Note( val amount: BigDecimal, ) - private fun processZapAmountFromResponse( + private suspend fun processZapAmountFromResponse( nwcRequest: LnZapPaymentRequestEvent, nwcResponse: LnZapPaymentResponseEvent, - continuation: Continuation, - signer: NostrSigner, - ) { + signerState: NwcSignerState, + ): InvoiceAmount? { // if we can decrypt the reply - nwcResponse.response(signer) { noteEvent -> + return signerState.decryptResponse(nwcResponse)?.let { noteEvent -> // if it is a sucess if (noteEvent is PayInvoiceSuccessResponse) { // if we can decrypt the invoice - nwcRequest.lnInvoice(signer) { invoice -> + val request = signerState.decryptRequest(nwcRequest) + val invoice = (request as? PayInvoiceMethod)?.params?.invoice + if (invoice != null) { // if we can parse the amount val amount = try { @@ -687,34 +687,32 @@ open class Note( // avoid double counting if (amount != null) { - continuation.resume(InvoiceAmount(invoice, amount)) + InvoiceAmount(invoice, amount) } else { - continuation.resume(null) + null } + } else { + null } } else { - continuation.resume(null) + null } } } - suspend fun zappedAmountWithNWCPayments( - signer: NostrSigner, - onReady: (BigDecimal) -> Unit, - ) { + suspend fun zappedAmountWithNWCPayments(signerState: NwcSignerState): BigDecimal { if (zapPayments.isEmpty()) { - onReady(zapsAmount) + return zapsAmount } val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } - zappedAmountCalculation( + return zappedAmountCalculation( zapsAmount, invoiceSet, zapPayments.toList(), - signer, - onReady, + signerState, ) } @@ -769,19 +767,27 @@ open class Note( fun hasReacted( loggedIn: User, content: String, - ): Boolean = reactedBy(loggedIn, content).isNotEmpty() + ): Boolean = allReactionsOfContentByAuthor(loggedIn, content).isNotEmpty() - fun reactedBy( + fun allReactionsOfContentByAuthor( loggedIn: User, content: String, ): List = reactions[content]?.filter { it.author == loggedIn } ?: emptyList() - fun reactedBy(loggedIn: User): List = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } + fun allReactionsByAuthor(loggedIn: User): List = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { - return boosts.firstOrNull { - it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() - } != null // 5 minute protection + val fiveMinsAgo = TimeUtils.fiveMinutesAgo() + return boosts.any { + it.author == loggedIn && (it.createdAt() ?: 0) > fiveMinsAgo + } + } + + fun hasBoostedInTheLast5Minutes(loggedIn: HexKey): Boolean { + val fiveMinsAgo = TimeUtils.fiveMinutesAgo() + return boosts.any { + (it.createdAt() ?: 0) > fiveMinsAgo && it.author?.pubkeyHex == loggedIn + } } fun boostedBy(loggedIn: User): List = boosts.filter { it.author == loggedIn } @@ -823,7 +829,7 @@ open class Note( zapsAmount = BigDecimal.ZERO } - fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { + fun isHiddenFor(accountChoices: HiddenUsersState.LiveHiddenUsers): Boolean { val thisEvent = event ?: return false val hash = thisEvent.pubKey.hashCode() @@ -971,7 +977,6 @@ class NoteFlowSet( metadata.hasObservers() || reports.hasObservers() || relays.hasObservers() || - metadata.hasObservers() || reactions.hasObservers() || boosts.hasObservers() || replies.hasObservers() || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt index 21b644ca45..8e1badf188 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -96,7 +96,7 @@ class ParticipantListBuilder { it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } } - LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it -> + LocalCache.getPublicChatChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it -> addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 8d2165304f..fe3c2f44d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -22,23 +22,21 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.note.toShortenHex -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache.RelayBriefInfo +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -46,7 +44,6 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.containsAny -import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal @@ -57,9 +54,8 @@ class User( var info: UserMetadata? = null var latestMetadata: MetadataEvent? = null - var latestMetadataRelay: String? = null + var latestMetadataRelay: NormalizedRelayUrl? = null var latestContactList: ContactListEvent? = null - var latestBookmarkList: BookmarkListEvent? = null var reports = mapOf>() private set @@ -67,32 +63,35 @@ class User( var zaps = mapOf() private set - var relaysBeingUsed = mapOf() - private set - - var privateChatrooms = mapOf() + var relaysBeingUsed = mapOf() private set fun pubkey() = Hex.decode(pubkeyHex) fun pubkeyNpub() = pubkey().toNpub() - fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() + fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay() + + fun dmInboxRelayList() = (LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(pubkeyHex))?.event as? ChatMessageRelayListEvent) fun authorRelayList() = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent) fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) - fun relayHints() = authorRelayList()?.writeRelays()?.take(3) ?: listOfNotNull(latestMetadataRelay) + fun outboxRelays() = authorRelayList()?.writeRelaysNorm() ?: listOfNotNull(latestMetadataRelay) - fun bestRelayHint() = authorRelayList()?.writeRelays()?.firstOrNull() ?: latestMetadataRelay + fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay) + + fun inboxRelays() = authorRelayList()?.readRelaysNorm() ?: listOfNotNull(latestMetadataRelay) + + fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() + + fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay fun toPTag() = PTag(pubkeyHex, bestRelayHint()) fun toNostrUri() = "nostr:${toNProfile()}" - override fun toString(): String = pubkeyHex - fun toBestShortFirstName(): String { val fullName = toBestDisplayName() @@ -115,13 +114,6 @@ class User( fun profilePicture(): String? = info?.picture - fun updateBookmark(event: BookmarkListEvent) { - if (event.id == latestBookmarkList?.id) return - - latestBookmarkList = event - flowSet?.bookmarks?.invalidateData() - } - fun updateContactList(event: ContactListEvent) { if (event.id == latestContactList?.id) return @@ -221,80 +213,13 @@ class User( } }.flatten() - @Synchronized - private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = - privateChatrooms[key] - ?: run { - val privateChatroom = Chatroom() - privateChatrooms = privateChatrooms + Pair(key, privateChatroom) - privateChatroom - } - - private fun getOrCreatePrivateChatroom(user: User): Chatroom { - val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) - return getOrCreatePrivateChatroom(key) - } - - private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) - - fun addMessage( - room: ChatroomKey, - msg: Note, - ) { - val privateChatroom = getOrCreatePrivateChatroom(room) - if (msg !in privateChatroom.roomMessages) { - privateChatroom.addMessageSync(msg) - flowSet?.messages?.invalidateData() - } - } - - fun addMessage( - user: User, - msg: Note, - ) { - val privateChatroom = getOrCreatePrivateChatroom(user) - if (msg !in privateChatroom.roomMessages) { - privateChatroom.addMessageSync(msg) - flowSet?.messages?.invalidateData() - } - } - - fun createChatroom(withKey: ChatroomKey) { - getOrCreatePrivateChatroom(withKey) - } - - fun removeMessage( - user: User, - msg: Note, - ) { - checkNotInMainThread() - - val privateChatroom = getOrCreatePrivateChatroom(user) - if (msg in privateChatroom.roomMessages) { - privateChatroom.removeMessageSync(msg) - flowSet?.messages?.invalidateData() - } - } - - fun removeMessage( - room: ChatroomKey, - msg: Note, - ) { - checkNotInMainThread() - val privateChatroom = getOrCreatePrivateChatroom(room) - if (msg in privateChatroom.roomMessages) { - privateChatroom.removeMessageSync(msg) - flowSet?.messages?.invalidateData() - } - } - fun addRelayBeingUsed( - relay: RelayBriefInfo, + relay: NormalizedRelayUrl, eventTime: Long, ) { - val here = relaysBeingUsed[relay.url] + val here = relaysBeingUsed[relay] if (here == null) { - relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1)) + relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1)) } else { if (eventTime > here.lastEvent) { here.lastEvent = eventTime @@ -334,19 +259,12 @@ class User( suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - fun hasSentMessagesTo(key: ChatroomKey?): Boolean { - val messagesToUser = privateChatrooms[key] ?: return false - - return messagesToUser.authors.any { this == it } - } - fun hasReport( loggedIn: User, type: ReportType, ): Boolean = reports[loggedIn]?.firstOrNull { - it.event is ReportEvent && - (it.event as ReportEvent).reportedAuthor().any { it.type == type } + (it.event as? ReportEvent)?.reportedAuthor()?.any { it.type == type } ?: false } != null fun containsAny(hiddenWordsCase: List): Boolean { @@ -424,10 +342,8 @@ class UserFlowSet( val relays = UserBundledRefresherFlow(u) val followers = UserBundledRefresherFlow(u) val reports = UserBundledRefresherFlow(u) - val messages = UserBundledRefresherFlow(u) val relayInfo = UserBundledRefresherFlow(u) val zaps = UserBundledRefresherFlow(u) - val bookmarks = UserBundledRefresherFlow(u) val statuses = UserBundledRefresherFlow(u) fun isInUse(): Boolean = @@ -436,16 +352,14 @@ class UserFlowSet( follows.hasObservers() || followers.hasObservers() || reports.hasObservers() || - messages.hasObservers() || relayInfo.hasObservers() || zaps.hasObservers() || - bookmarks.hasObservers() || statuses.hasObservers() } @Immutable data class RelayInfo( - val url: String, + val url: NormalizedRelayUrl, var lastEvent: Long, var counter: Long, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListDecryptionCache.kt new file mode 100644 index 0000000000..ca8e735495 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.edits + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent + +class PrivateStorageRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt new file mode 100644 index 0000000000..e4f28c081d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.edits + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class PrivateStorageRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: PrivateStorageRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getPrivateOutboxRelayListAddress() = PrivateOutboxRelayListEvent.createAddress(signer.pubKey) + + fun getPrivateOutboxRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getPrivateOutboxRelayListAddress()) + + fun getPrivateOutboxRelayListFlow(): StateFlow = getPrivateOutboxRelayListNote().flow().metadata.stateFlow + + fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent + + suspend fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { + val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getPrivateOutboxRelayListFlow() + .map { normalizePrivateOutboxRelayListWithBackup(it.note) } + .onStart { + emit(normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(relays: List): PrivateOutboxRelayListEvent { + val relayListForPrivateOutbox = getPrivateOutboxRelayList() + + return if (relayListForPrivateOutbox != null) { + PrivateOutboxRelayListEvent.updateRelayList( + earlierVersion = relayListForPrivateOutbox, + relays = relays, + signer = signer, + ) + } else { + PrivateOutboxRelayListEvent.create( + relays = relays, + signer = signer, + ) + } + } + + init { + settings.backupPrivateHomeRelayList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") + getPrivateOutboxRelayListFlow().collect { noteState -> + Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}") + (noteState.note.event as? PrivateOutboxRelayListEvent)?.let { + settings.updatePrivateHomeRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatChannel.kt new file mode 100644 index 0000000000..12a28673d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatChannel.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.emphChat + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId + +@Stable +class EphemeralChatChannel( + val roomId: RoomId, +) : Channel() { + override fun relays() = setOf(roomId.relayUrl) + + override fun toBestDisplayName() = roomId.toDisplayKey() + + fun anyNameStartsWith(prefix: String): Boolean = roomId.id.contains(prefix, true) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt new file mode 100644 index 0000000000..60ff2f80fc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.emphChat + +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.roomSet +import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache + +class EphemeralChatListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedRoomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).roomSet() + + fun cachedRooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).rooms() + + suspend fun roomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).roomSet() + + suspend fun rooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).rooms() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt index 8cf9ecfb59..0a9378ebb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt @@ -20,29 +20,34 @@ */ package com.vitorpamplona.amethyst.model.emphChat +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest -import kotlin.coroutines.resume +import kotlinx.coroutines.launch class EphemeralChatListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: EphemeralChatListDecryptionCache, val scope: CoroutineScope, + val settings: AccountSettings, ) { fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey) @@ -52,77 +57,74 @@ class EphemeralChatListState( fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent + suspend fun ephemeralChatListWithBackup(note: Note): Set { + val event = note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList + return event?.let { decryptionCache.roomSet(it) } ?: emptySet() + } + @OptIn(ExperimentalCoroutinesApi::class) - val liveEphemeralChatList: StateFlow> by lazy { + val liveEphemeralChatList: StateFlow> = getEphemeralChatListFlow() .transformLatest { noteState -> - val set = - tryAndWait { continuation -> - (noteState.note.event as? EphemeralChatListEvent)?.publicAndPrivateRoomIds(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(ephemeralChatListWithBackup(noteState.note)) }.onStart { - val set = - tryAndWait { continuation -> - getEphemeralChatList()?.publicAndPrivateRoomIds(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(ephemeralChatListWithBackup(getEphemeralChatListNote())) }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, emptySet(), ) - } - fun follow( - channel: EphemeralChatChannel, - onDone: (EphemeralChatListEvent) -> Unit, - ) { + suspend fun follow(channel: EphemeralChatChannel): EphemeralChatListEvent { val ephemeralChatList = getEphemeralChatList() - if (ephemeralChatList == null) { - EphemeralChatListEvent.createRoom( + return if (ephemeralChatList == null) { + EphemeralChatListEvent.create( room = channel.roomId, isPrivate = true, signer = signer, - onReady = onDone, ) } else { - EphemeralChatListEvent.addRoom( + EphemeralChatListEvent.add( earlierVersion = ephemeralChatList, room = channel.roomId, isPrivate = true, signer = signer, - onReady = onDone, ) } } - fun unfollow( - channel: EphemeralChatChannel, - onDone: (EphemeralChatListEvent) -> Unit, - ) { + suspend fun unfollow(channel: EphemeralChatChannel): EphemeralChatListEvent? { val ephemeralChatList = getEphemeralChatList() - - if (ephemeralChatList != null) { - EphemeralChatListEvent.removeRoom( + return if (ephemeralChatList != null) { + EphemeralChatListEvent.remove( earlierVersion = ephemeralChatList, room = channel.roomId, - isPrivate = true, signer = signer, - onReady = onDone, ) + } else { + null + } + } + + init { + settings.backupEphemeralChatList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start") + getEphemeralChatListFlow().collect { noteState -> + Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}") + (noteState.note.event as? EphemeralChatListEvent)?.let { + settings.updateEphemeralChatListTo(it) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt new file mode 100644 index 0000000000..dcdaa2b36a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.localRelays + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class LocalRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun normalizeLocalRelayListWithBackup(relayList: Set): Set = relayList.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + val flow = + settings.localRelayServers + .map { normalizeLocalRelayListWithBackup(it) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + normalizeLocalRelayListWithBackup(settings.localRelayServers.value), + ) + + fun saveRelayList( + relays: List, + onDone: () -> Unit, + ) { + settings.updateLocalRelayServers(relays.map { it.url }.toSet()) + onDone() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt new file mode 100644 index 0000000000..58de33ab47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip01UserMetadata + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn + +class AccountOutboxRelayState( + nip65: Nip65RelayListState, + privateStorage: PrivateStorageRelayListState, + local: LocalRelayListState, + broadcast: BroadcastRelayListState, + scope: CoroutineScope, +) { + val flow = + combine( + nip65.outboxFlow, + privateStorage.flow, + local.flow, + broadcast.flow, + ) { nip65Inbox, privateOutBox, localRelays, broadcastRelays -> + nip65Inbox + privateOutBox + localRelays + broadcastRelays + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65.outboxFlow.value + + privateStorage.flow.value + + local.flow.value + + broadcast.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt index 0200cb0e58..1d05dee9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt @@ -18,39 +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.relays +package com.vitorpamplona.amethyst.model.nip01UserMetadata -import android.app.Application -import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager -import com.vitorpamplona.amethyst.ui.tor.TorManager +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn -/** - * There should be only one instance of the Tor binding per app. - * - * Tor will connect as soon as status is listened to. - */ -class RelayManager( - app: Application, +class NotificationInboxRelayState( + nip65RelayList: Nip65RelayListState, + localRelayList: LocalRelayListState, scope: CoroutineScope, - torManager: TorManager, - connManager: ConnectivityManager, ) { - val relayService = + val flow = combine( - torManager.status, - connManager.status, - ) { torStatus, connManager -> - } - - val status: StateFlow = - RelayService(app).status.stateIn( - scope, - SharingStarted.WhileSubscribed(30000), - RelayServiceStatus.Off, - ) + nip65RelayList.inboxFlow, + localRelayList.flow, + ) { nip65Inbox, localRelays -> + nip65Inbox + localRelays + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65RelayList.inboxFlow.value + localRelayList.flow.value, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt new file mode 100644 index 0000000000..0061c0385f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip01UserMetadata + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class UserMetadataState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey) + + fun getUserMetadataUser(): User = cache.getOrCreateUser(signer.pubKey) + + fun getUserMetadataFlow(): StateFlow = getUserMetadataUser().flow().metadata.stateFlow + + fun getUserMetadataEvent(): MetadataEvent? = getUserMetadataUser().latestMetadata + + suspend fun sendNewUserMetadata( + name: String? = null, + picture: String? = null, + banner: String? = null, + website: String? = null, + pronouns: String? = null, + about: String? = null, + nip05: String? = null, + lnAddress: String? = null, + lnURL: String? = null, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + ): MetadataEvent { + val latest = getUserMetadataEvent() + + val template = + if (latest != null) { + MetadataEvent.updateFromPast( + latest = latest, + name = name, + displayName = name, + picture = picture, + banner = banner, + website = website, + pronouns = pronouns, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } else { + MetadataEvent.createNew( + name = name, + displayName = name, + picture = picture, + banner = banner, + website = website, + pronouns = pronouns, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } + + return signer.sign(template) + } + + init { + settings.backupUserMetadata?.let { + Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Kind 0 Collector Start") + getUserMetadataFlow().collect { + Log.d("AccountRegisterObservers", "Updating Kind 0 ${it.user.toBestDisplayName()}") + settings.updateUserMetadata(it.user.latestMetadata) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt new file mode 100644 index 0000000000..cf90954fbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip02FollowLists + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlin.collections.flatten + +class FollowListOutboxOrProxyRelays( + kind3Follows: FollowListState, + blockedRelayList: BlockedRelayListState, + proxyRelayList: ProxyRelayListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow> = + combine(flows) { relayListNotes: Array -> + relayListNotes + .mapNotNull { + (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() + }.flatten() + .toSet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val outboxRelayFlow: StateFlow> = + kind3Follows.flow + .transformLatest { + emitAll(combineAllFlows(allRelayListFlows(it.authors))) + }.onStart { + emit( + kind3Follows.flow.value.authors + .mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm() + }.flatten() + .toSet(), + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val outboxRelayMinusBlockedFlow: StateFlow> = + combine(outboxRelayFlow, blockedRelayList.flow) { followList, blockedRelays -> + followList.minus(blockedRelays) + }.onStart { + emit(outboxRelayFlow.value.minus(blockedRelayList.flow.value.toSet())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + proxyRelayList.flow + .flatMapLatest { proxyRelays -> + if (proxyRelays.isEmpty()) { + outboxRelayMinusBlockedFlow + } else { + MutableStateFlow(proxyRelays) + } + }.onStart { + emit( + proxyRelayList.flow.value.ifEmpty { + outboxRelayMinusBlockedFlow.value + }, + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow + .map { relayList -> + relayList.map { it.url }.toSet() + }.onStart { + emit(flow.value.map { it.url }.toSet()) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt new file mode 100644 index 0000000000..409f31e154 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip02FollowLists + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class FollowListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey) + + fun getFollowListUser(): User = cache.getOrCreateUser(signer.pubKey) + + fun getFollowListFlow(): StateFlow = getFollowListUser().flow().follows.stateFlow + + fun getFollowListEvent(): ContactListEvent? = getFollowListUser().latestContactList + + @OptIn(ExperimentalCoroutinesApi::class) + private val innerFlow: Flow = + getFollowListFlow().transformLatest { + emit(buildKind3Follows(it.user.latestContactList ?: settings.backupContactList)) + } + + val flow = + innerFlow + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + buildKind3Follows(getFollowListEvent() ?: settings.backupContactList), + ) + + /** + This contains a big OR of everything the user wants to see in the a single feed. + */ + @Immutable + class Kind3Follows( + val authors: Set = emptySet(), + val authorsPlusMe: Set, + val hashtags: Set = emptySet(), + val geotags: Set = emptySet(), + val communities: Set = emptySet(), + ) { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + } + + fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows { + // makes sure the output include only valid p tags + val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() + + return Kind3Follows( + authors = verifiedFollowingUsers, + authorsPlusMe = verifiedFollowingUsers + signer.pubKey, + hashtags = + latestContactList + ?.unverifiedFollowTagSet() + ?.map { it.lowercase() } + ?.toSet() ?: emptySet(), + geotags = + latestContactList + ?.geohashes() + ?.toSet() ?: emptySet(), + communities = + latestContactList + ?.verifiedFollowAddressSet() + ?.toSet() ?: emptySet(), + ) + } + + suspend fun follow(user: User): ContactListEvent { + val contactList = getFollowListEvent() + + return if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun unfollow(user: User): ContactListEvent? { + val contactList = getFollowListEvent() + + return if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + ) + } else { + null + } + } + + init { + settings.backupContactList?.let { + Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Kind 3 Collector Start") + getFollowListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}") + settings.updateContactListTo(it.user.latestContactList) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt new file mode 100644 index 0000000000..4f6fe71288 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip02FollowLists + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class FollowsPerOutboxRelay( + kind3Follows: FollowListState, + blockedRelayList: BlockedRelayListState, + proxyRelayList: ProxyRelayListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllRelayListFlows(flows: List>): Flow>> = + combine(flows) { relayListNotes: Array -> + mapOfSet { + relayListNotes.forEach { noteState -> + noteState.note.author?.pubkeyHex?.let { authorHex -> + (noteState.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.forEach { relay -> + add(relay, authorHex) + } + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val outboxPerRelayFlow: StateFlow>> = + kind3Follows.flow + .transformLatest { + emitAll(combineAllRelayListFlows(allRelayListFlows(it.authors))) + }.onStart { + emit( + mapOfSet { + kind3Follows.flow.value.authors.map { authorHex -> + getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay -> + add(relay, authorHex) + } + } + }, + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) + + val outboxPerRelayMinusBlockedFlow: StateFlow>> = + combine(outboxPerRelayFlow, blockedRelayList.flow) { followList, blockedRelays -> + followList.minus(blockedRelays) + }.onStart { + emit(outboxPerRelayFlow.value.minus(blockedRelayList.flow.value.toSet())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow>> = + proxyRelayList.flow + .flatMapLatest { proxyRelays -> + if (proxyRelays.isEmpty()) { + outboxPerRelayMinusBlockedFlow + } else { + kind3Follows.flow.map { follows -> + proxyRelays.associateWith { follows.authors } + } + } + }.onStart { + if (proxyRelayList.flow.value.isEmpty()) { + emit(outboxPerRelayMinusBlockedFlow.value) + } else { + emit(proxyRelayList.flow.value.associateWith { kind3Follows.flow.value.authors }) + } + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt new file mode 100644 index 0000000000..44f316caad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip03Timestamp + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.update +import java.util.Base64 + +class OtsState( + val signer: NostrSigner, + val cache: LocalCache, + val otsResolver: OkHttpOtsResolverBuilder, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + suspend fun updateAttestations(): List { + Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") + + return settings.pendingAttestations.value.toList().mapNotNull { (key, value) -> + val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver.build()) + + if (otsState != null) { + val hint = cache.getNoteIfExists(key)?.toEventHint() + val template = + if (hint != null) { + OtsEvent.build(hint, otsState) + } else { + OtsEvent.build(key, otsState) + } + + val otsEvent = signer.sign(template) + + settings.pendingAttestations.update { it - key } + + otsEvent + } else { + null + } + } + } + + fun hasPendingAttestations(note: Note): Boolean { + val id = note.event?.id ?: note.idHex + return settings.pendingAttestations.value[id] != null + } + + fun timestamp(note: Note) { + if (note.isDraft()) return + + val id = note.event?.id ?: note.idHex + + settings.addPendingAttestation( + id = id, + stamp = + Base64.getEncoder().encodeToString( + OtsEvent.stamp( + id, + otsResolver.build(), + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt new file mode 100644 index 0000000000..d1e9def879 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip17Dms + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn + +class DmInboxRelayState( + // main relay + dmRelayList: DmRelayListState, + // backup relays + nip65RelayList: Nip65RelayListState, + privateOutbox: PrivateStorageRelayListState, + localRelayList: LocalRelayListState, + scope: CoroutineScope, +) { + val flow = + combine( + nip65RelayList.inboxFlow, + dmRelayList.flow, + privateOutbox.flow, + localRelayList.flow, + ) { nip65Inbox, dmRelayList, privateOutBox, localRelays -> + nip65Inbox + dmRelayList + privateOutBox + localRelays + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65RelayList.inboxFlow.value + + dmRelayList.flow.value + + privateOutbox.flow.value + + localRelayList.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt new file mode 100644 index 0000000000..08e3b4111a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip17Dms + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DmRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getDMRelayListAddress() = ChatMessageRelayListEvent.createAddress(signer.pubKey) + + fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getDMRelayListAddress()) + + fun getDMRelayListFlow(): StateFlow = getDMRelayListNote().flow().metadata.stateFlow + + fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent + + fun normalizeDMRelayListWithBackup(note: Note): Set { + val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList + return event?.relays()?.toSet() ?: emptySet() + } + + val flow = + getDMRelayListFlow() + .map { normalizeDMRelayListWithBackup(it.note) } + .onStart { emit(normalizeDMRelayListWithBackup(getDMRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(dmRelays: List): ChatMessageRelayListEvent { + val relayListForDMs = getDMRelayList() + return if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { + ChatMessageRelayListEvent.updateRelayList( + earlierVersion = relayListForDMs, + relays = dmRelays, + signer = signer, + ) + } else { + ChatMessageRelayListEvent.create( + relays = dmRelays, + signer = signer, + ) + } + } + + init { + settings.backupDMRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(it) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") + getDMRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating DM Relay List for ${signer.pubKey}") + (it.note.event as? ChatMessageRelayListEvent)?.let { + settings.updateDMRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt new file mode 100644 index 0000000000..4d5d1276d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip18Reposts + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +class RepostAction { + companion object { + suspend fun repost( + note: Note, + signer: NostrSigner, + ): Event? { + val noteEvent = note.event ?: return null + + if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) { + // has already bosted in the past 5mins + return null + } + + val noteHint = note.relayHintUrl() + val authorHint = note.author?.bestRelayHint() + + val template = + if (noteEvent.kind == 1) { + RepostEvent.build(noteEvent, noteHint, authorHint) + } else { + GenericRepostEvent.build(noteEvent, noteHint, authorHint) + } + + return signer.sign(template) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt new file mode 100644 index 0000000000..bf580d23c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip25Reactions + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag + +class ReactionAction { + companion object { + suspend fun reactTo( + note: Note, + reaction: String, + by: User, + signer: NostrSigner, + onPublic: (ReactionEvent) -> Unit, + onPrivate: suspend (NIP17Factory.Result) -> Unit, + ) { + if (!signer.isWriteable()) return + + if (note.hasReacted(by, reaction)) { + // has already liked this note + return + } + + val noteEvent = note.event + if (noteEvent is NIP17Group) { + val users = noteEvent.groupMembers().toList() + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + note.toEventHint()?.let { + onPrivate( + NIP17Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = it, + to = users, + signer = signer, + ), + ) + } + + return + } + } + + note.toEventHint()?.let { + onPrivate( + NIP17Factory().createReactionWithinGroup( + content = reaction, + originalNote = it, + to = users, + signer = signer, + ), + ) + } + return + } else { + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + note.event?.let { + val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())) + + onPublic(signer.sign(template)) + } + + return + } + } + + note.toEventHint()?.let { + onPublic(signer.sign(ReactionEvent.build(reaction, it))) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatChannel.kt new file mode 100644 index 0000000000..e7ea766ebe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatChannel.kt @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip28PublicChats + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.toShortDisplay +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional +import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList +import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm + +@Stable +class PublicChatChannel( + val idHex: String, +) : Channel() { + var creator: User? = null + var event: ChannelCreateEvent? = null + + var info = ChannelDataNorm(null, null, null, null) + var infoTags = EmptyTagList + var updatedMetadataAt: Long = 0 + + override fun relays() = info.relays?.toSet() ?: super.relays() + + fun relayHintUrls() = relays().take(3) + + fun relayHintUrl() = relays().firstOrNull() + + fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, relayHintUrls()) + + fun toNostrUri() = "nostr:${toNEvent()}" + + fun toEventHint() = event?.let { EventHintBundle(it, relayHintUrl(), null) } + + fun toEventId() = EventIdHintOptional(idHex, relayHintUrl()) + + fun updateChannelInfo( + creator: User, + event: ChannelCreateEvent, + ) { + this.creator = creator + this.event = event + + this.info = event.channelInfo() + this.infoTags = event.tags.toImmutableListOfLists() + this.updatedMetadataAt = event.createdAt + + updateChannelInfo() + } + + fun updateChannelInfo( + creator: User, + event: ChannelMetadataEvent, + ) { + this.creator = creator + + this.info = event.channelInfo() + this.infoTags = event.tags.toImmutableListOfLists() + this.updatedMetadataAt = event.createdAt + + super.updateChannelInfo() + } + + override fun toBestDisplayName(): String = info.name ?: toNEvent().toShortDisplay() + + fun summary(): String? = info.about + + fun profilePicture(): String? { + if (info.picture.isNullOrBlank()) return creator?.info?.banner + return info.picture ?: creator?.info?.banner + } + + fun anyNameStartsWith(prefix: String): Boolean = + idHex.startsWith(prefix) || + info.name?.contains(prefix, true) == true || + info.about?.contains(prefix, true) == true +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt new file mode 100644 index 0000000000..a0a54ff93d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip28PublicChats + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.channelSet +import com.vitorpamplona.quartz.nip28PublicChat.list.channels +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache + +class PublicChatListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedChannelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channelSet() + + fun cachedChannels(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channels() + + suspend fun channelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channelSet() + + suspend fun channels(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channels() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt index 64368a690b..8ee8452d52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt @@ -20,18 +20,21 @@ */ package com.vitorpamplona.amethyst.model.nip28PublicChats +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn @@ -39,12 +42,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest -import kotlin.coroutines.resume +import kotlinx.coroutines.launch class PublicChatListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: PublicChatListDecryptionCache, val scope: CoroutineScope, + val settings: AccountSettings, ) { fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey) @@ -54,97 +59,87 @@ class PublicChatListState( fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent + suspend fun publicChatListWithBackup(note: Note): Set { + val event = note.event as? ChannelListEvent ?: settings.backupChannelList + return event?.let { decryptionCache.channelSet(it) } ?: emptySet() + } + @OptIn(ExperimentalCoroutinesApi::class) - val livePublicChatList: StateFlow> by lazy { + val flow: StateFlow> = getChannelListFlow() .transformLatest { noteState -> - val set = - tryAndWait { continuation -> - (noteState.note.event as? ChannelListEvent)?.publicAndPrivateChannels(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(publicChatListWithBackup(noteState.note)) }.onStart { - val set = - tryAndWait { continuation -> - getChannelList()?.publicAndPrivateChannels(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(publicChatListWithBackup(getChannelListNote())) }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, emptySet(), ) - } @OptIn(ExperimentalCoroutinesApi::class) - val livePublicChatEventIdSet: StateFlow> by lazy { - livePublicChatList + val flowSet: StateFlow> = + flow .map { it.mapTo(mutableSetOf()) { it.eventId } + }.onStart { + emit(flow.value.mapTo(mutableSetOf()) { it.eventId }) }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, emptySet(), ) - } - fun follow( - channel: PublicChatChannel, - onDone: (ChannelListEvent) -> Unit, - ) { + suspend fun follow(channel: PublicChatChannel): ChannelListEvent { val publicChatList = getChannelList() - val fullHint = channel.toEventHint() - if (fullHint != null) { - if (publicChatList == null) { - ChannelListEvent.createChannel(fullHint, true, signer, onReady = onDone) - } else { - ChannelListEvent.addChannel(publicChatList, fullHint, true, signer, onReady = onDone) - } + return if (publicChatList == null) { + ChannelListEvent.create(ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer) } else { - val partialHint = channel.toEventId() - if (publicChatList == null) { - ChannelListEvent.createChannel(partialHint, true, signer, onReady = onDone) - } else { - ChannelListEvent.addChannel(publicChatList, partialHint, true, signer, onReady = onDone) - } + ChannelListEvent.add(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer) } } - fun follow( - channels: List, - onDone: (ChannelListEvent) -> Unit, - ) { + suspend fun follow(channels: List): ChannelListEvent { val publicChatList = getChannelList() - val partialHint = channels.map { it.toEventId() } - if (publicChatList == null) { - ChannelListEvent.createChannels(partialHint, true, signer, onReady = onDone) + val channelTags = channels.map { ChannelTag(it.idHex, it.relayHintUrl()) } + return if (publicChatList == null) { + ChannelListEvent.create(channelTags, true, signer) } else { - ChannelListEvent.addChannels(publicChatList, partialHint, true, signer, onReady = onDone) + ChannelListEvent.add(publicChatList, channelTags, true, signer) } } - fun unfollow( - channel: PublicChatChannel, - onDone: (ChannelListEvent) -> Unit, - ) { + suspend fun unfollow(channel: PublicChatChannel): ChannelListEvent? { val publicChatList = getChannelList() - if (publicChatList != null) { - ChannelListEvent.removeChannel(publicChatList, channel.idHex, signer, onReady = onDone) + return if (publicChatList != null) { + ChannelListEvent.remove(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), signer) + } else { + null + } + } + + init { + settings.backupChannelList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Channel List Collector Start") + getChannelListFlow().collect { + Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}") + (it.note.event as? ChannelListEvent)?.let { + settings.updateChannelListTo(it) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt index 4c98580122..b40392a63d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip30CustomEmojis import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses @@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest @@ -67,17 +69,18 @@ class EmojiPackState( } @OptIn(ExperimentalCoroutinesApi::class) - val liveEmojiSelectionPack: StateFlow>?> by lazy { + val flow: StateFlow>?> = getEmojiPackSelectionFlow() .transformLatest { emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent)) + }.onStart { + emit(convertEmojiSelectionPack(getEmojiPackSelection())) }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, - convertEmojiSelectionPack(getEmojiPackSelection()), + emptyList(), ) - } fun convertEmojiPack(pack: EmojiPackEvent): List = pack.taggedEmojis().map { @@ -97,8 +100,8 @@ class EmojiPackState( .distinctBy { it.link } @OptIn(ExperimentalCoroutinesApi::class) - val myEmojis by lazy { - liveEmojiSelectionPack + val myEmojis = + flow .transformLatest { emojiList -> if (emojiList != null) { emitAll( @@ -109,11 +112,44 @@ class EmojiPackState( } else { emit(emptyList()) } + }.onStart { + emit( + mergePack( + convertEmojiSelectionPack( + getEmojiPackSelection(), + )?.map { it.value }?.toTypedArray() ?: emptyArray(), + ), + ) }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, - mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()), + emptyList(), ) + + suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent { + val emojiPackEvent = emojiPack.event + if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.") + + val eventHint = emojiPack.toEventHint() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.") + + val usersEmojiList = getEmojiPackSelection() + return if (usersEmojiList == null) { + val template = EmojiPackSelectionEvent.build(listOf(eventHint)) + signer.sign(template) + } else { + val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint) + signer.sign(template) + } + } + + suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? { + val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("Cannot remove an emoji pack to this kind of event.") + + val emojiPackEvent = emojiPack.event + if (emojiPackEvent !is EmojiPackEvent) return null + + val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent) + return signer.sign(template) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt new file mode 100644 index 0000000000..834e2437f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip38UserStatuses + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent + +class UserStatusAction { + companion object { + suspend fun create( + newStatus: String, + signer: NostrSigner, + ): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer) + + suspend fun update( + oldStatus: AddressableNote, + newStatus: String, + signer: NostrSigner, + ): StatusEvent { + val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event") + + return StatusEvent.update(oldEvent, newStatus, signer) + } + + suspend fun delete( + oldStatus: AddressableNote, + signer: NostrSigner, + ): List { + val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event") + + val event = StatusEvent.clear(oldEvent, signer) + + val deletion = + signer.sign( + DeletionEvent.buildForVersionOnly(listOf(event)), + ) + + return listOf(event, deletion) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt new file mode 100644 index 0000000000..9444bedbab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip47WalletConnect + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache +import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account. + * + * Key Responsibilities: + * + * - Dynamically creates a NIP-47 signer if the wallet setup changes in the account settings. + * - Provides decryption caches to manage decrypted NIP-47 requests and responses efficiently. + * - Handles creating of zap payment requests and waits for responses. + * + * @property signer the main Nostr signer used for general Nostr operations + * @property cache the local cache for handling notes and events + * @property scope the coroutine scope used for async operations + * @property settings the account settings containing NIP-47 configuration + */ +class NwcSignerState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + /** + * Derives a NIP-47 signer from the zap payment request in settings. + * If there's no valid configuration, it defaults to the main signer. + * Flows updates whenever settings change. + */ + val nip47Signer = + settings.zapPaymentRequest + .map { + buildSigner(it) ?: signer + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + buildSigner(settings.zapPaymentRequest.value) ?: signer, + ) + + /** + * Creates a dedicated request decryption cache for the NIP-47 signer. + * Flows updates whenever the signer changes. + */ + val zapPaymentRequestDecryptionCache = + nip47Signer + .map { + NostrWalletConnectRequestCache(it) + }.flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectRequestCache(nip47Signer.value)) + + /** + * Creates a dedicated response decryption cache for the NIP-47 signer. + * Flows updates whenever the signer changes. + */ + val zapPaymentResponseDecryptionCache = + nip47Signer + .map { + NostrWalletConnectResponseCache(it) + }.flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectResponseCache(nip47Signer.value)) + + fun buildSigner(uri: Nip47WalletConnect.Nip47URINorm?) = + uri?.secret?.hexToByteArray()?.let { + NostrSignerInternal(KeyPair(it)) + } + + fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest.value != null + + fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex + + /** + * Decrypts a NIP-47 payment request using the current signer. + * + * @param nwcRequest the NIP-47 payment request event to decrypt + * @return the decrypted request or null if not set up or decryption fails + */ + suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? { + if (!hasWalletConnectSetup()) return null + return zapPaymentRequestDecryptionCache.value.decryptRequest(nwcRequest) + } + + /** + * Decrypts a NIP-47 payment response using the current signer. + * + * @param nwsResponse the NIP-47 payment response event to decrypt + * @return the decrypted response or null if not set up or decryption fails + */ + suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? { + if (!hasWalletConnectSetup()) return null + return zapPaymentResponseDecryptionCache.value.decryptResponse(nwsResponse) + } + + /** + * Sends a zap payment request to a connected Lightning wallet. + * Subscribes to responses and waits up to 60s for a reply. + * + * @param bolt11 the BOLT-11 invoice to pay + * @param zappedNote the note being zapped (if any) + * @param onResponse callback to handle the response from the wallet + * @return a pair containing the payment request event and target relay URL + * @throws IllegalArgumentException if no NIP-47 wallet is set up + */ + suspend fun sendZapPaymentRequestFor( + bolt11: String, + zappedNote: Note?, + onResponse: (Response?) -> Unit, + ): Pair { + val walletService = settings.zapPaymentRequest.value + if (walletService == null) throw IllegalArgumentException("No NIP47 setup") + + val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, signer) + + val filter = + NWCPaymentQueryState( + fromServiceHex = walletService.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + relay = walletService.relayUri, + ) + + Amethyst.instance.sources.nwc + .subscribe(filter) + + scope.launch(Dispatchers.IO) { + delay(60000) // waits 1 minute to complete payment. + Amethyst.instance.sources.nwc + .unsubscribe(filter) + } + + cache.consume(event, zappedNote, true, walletService.relayUri) { + onResponse(decryptResponse(it)) + } + + return Pair(event, walletService.relayUri) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt new file mode 100644 index 0000000000..57069ef61b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -0,0 +1,276 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class BookmarkListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + class BookmarkList( + val public: List = emptyList(), + val private: List = emptyList(), + ) + + fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey) + + fun getBookmarkListNote() = cache.getOrCreateAddressableNote(getBookmarkListAddress()) + + fun getBookmarkListFlow(): StateFlow = getBookmarkListNote().flow().metadata.stateFlow + + fun getBookmarkList(): BookmarkListEvent? = getBookmarkListNote().event as? BookmarkListEvent + + suspend fun publicBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.publicBookmarks() ?: emptyList() + } + + suspend fun privateBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.privateBookmarks(signer) ?: emptyList() + } + + @OptIn(FlowPreview::class) + val publicBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + publicBookmarks(noteState.note) + }.onStart { + emit(publicBookmarks(getBookmarkListNote())) + }.debounce(100) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + @OptIn(FlowPreview::class) + val privateBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + privateBookmarks(noteState.note) + }.onStart { + emit(privateBookmarks(getBookmarkListNote())) + }.debounce(100) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkEventIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkAddressIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkEventIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkAddressIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun bookmarkList( + privateBookmarks: List, + publicBookmarks: List, + ): BookmarkList = + BookmarkList( + public = + publicBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + private = + privateBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + ) + + @OptIn(FlowPreview::class) + val bookmarks: StateFlow = + combineTransform(privateBookmarks, publicBookmarks) { private, public -> + emit(bookmarkList(private, public)) + }.onStart { + emit(bookmarkList(privateBookmarks.value, publicBookmarks.value)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + BookmarkList(), + ) + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!signer.isWriteable()) return false + + return if (note is AddressableNote) { + privateBookmarkAddressIdSet.value.contains(note.address) + } else { + privateBookmarkEventIdSet.value.contains(note.idHex) + } + } + + fun isInPublicBookmarks(note: Note): Boolean = + if (note is AddressableNote) { + publicBookmarkAddressIdSet.value.contains(note.address) + } else { + publicBookmarkEventIdSet.value.contains(note.idHex) + } + + suspend fun addBookmark( + note: Note, + isPrivate: Boolean, + ): BookmarkListEvent { + val bookmarkList = getBookmarkList() + + return if (bookmarkList == null) { + if (note is AddressableNote) { + BookmarkListEvent.create( + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.create( + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } else { + if (note is AddressableNote) { + BookmarkListEvent.add( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.add( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } + } + + suspend fun removeBookmark( + note: Note, + isPrivate: Boolean, + ): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt new file mode 100644 index 0000000000..ce359259b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +class HiddenUsersState( + val muteList: StateFlow>, + val blockList: StateFlow>, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) + + @Immutable + class LiveHiddenUsers( + val hiddenUsers: Set, + val spammers: Set, + val hiddenWords: Set, + val showSensitiveContent: Boolean?, + ) { + // speeds up isHidden calculations + val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() } + val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() } + val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) } + + fun isUserHidden(userHex: HexKey) = hiddenUsers.contains(userHex) || spammers.contains(userHex) + } + + suspend fun assembleLiveHiddenUsers( + blockList: List, + muteList: List, + transientHiddenUsers: Set, + showSensitiveContent: Boolean?, + ): LiveHiddenUsers = + LiveHiddenUsers( + hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null }, + hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null }, + spammers = transientHiddenUsers, + showSensitiveContent = showSensitiveContent, + ) + + val flow: StateFlow = + combineTransform( + blockList, + muteList, + transientHiddenUsers, + settings.syncedSettings.security.showSensitiveContent, + ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> + checkNotInMainThread() + emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent)) + }.onStart { + emit( + assembleLiveHiddenUsers( + blockList.value, + muteList.value, + transientHiddenUsers.value, + settings.syncedSettings.security.showSensitiveContent.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + LiveHiddenUsers(emptySet(), emptySet(), emptySet(), null), + ) + + fun resetTransientUsers() { + transientHiddenUsers.update { + emptySet() + } + } + + fun showUser(pubkeyHex: HexKey) { + transientHiddenUsers.update { it - pubkeyHex } + } + + fun hideUser(pubkeyHex: HexKey) { + transientHiddenUsers.update { it + pubkeyHex } + } + + fun isHidden(pubkeyHex: HexKey) = pubkeyHex in transientHiddenUsers.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt new file mode 100644 index 0000000000..2bf5a11cb2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class BlockPeopleListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: PeopleListDecryptionCache, + val scope: CoroutineScope, +) { + fun getBlockListAddress() = PeopleListEvent.createBlockAddress(signer.pubKey) + + fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(getBlockListAddress()) + + fun getBlockListFlow(): StateFlow = getBlockListNote().flow().metadata.stateFlow + + fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent + + suspend fun blockListWithBackup(note: Note): List { + val event = note.event as? PeopleListEvent + return event?.let { decryptionCache.usersAndWords(it) } ?: emptyList() + } + + val flow = + getBlockListFlow() + .map { blockListWithBackup(it.note) } + .onStart { emit(blockListWithBackup(getBlockListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun hideUser(pubkeyHex: String): PeopleListEvent { + val blockList = getBlockList() + + return if (blockList != null) { + PeopleListEvent.add( + earlierVersion = blockList, + person = UserTag(pubkeyHex), + isPrivate = true, + signer = signer, + ) + } else { + PeopleListEvent.create( + name = PeopleListEvent.BLOCK_LIST_D_TAG, + person = UserTag(pubkeyHex), + isPrivate = true, + signer = signer, + dTag = PeopleListEvent.BLOCK_LIST_D_TAG, + ) + } + } + + suspend fun showUser(pubkeyHex: String): PeopleListEvent? { + val blockList = getBlockList() + + return if (blockList != null) { + PeopleListEvent.remove( + earlierVersion = blockList, + person = UserTag(pubkeyHex), + signer = signer, + ) + } else { + null + } + } + + suspend fun showWord(word: String): PeopleListEvent? { + val blockList = getBlockList() + + return if (blockList != null) { + PeopleListEvent.remove( + earlierVersion = blockList, + person = WordTag(word), + signer = signer, + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt new file mode 100644 index 0000000000..23d5ed4b39 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent + +class PeopleListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedUsersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords() + + fun cachedUsers(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers() + + fun cachedUserIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet() + + fun cachedWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords() + + fun cachedWordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet() + + suspend fun usersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords() + + suspend fun users(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers() + + suspend fun userIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet() + + suspend fun words(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() + + suspend fun wordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListDecryptionCache.kt new file mode 100644 index 0000000000..a3f3df679f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent + +class BlockedRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt new file mode 100644 index 0000000000..394bfd1bac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class BlockedRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: BlockedRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getBlockedRelayListAddress() = BlockedRelayListEvent.createAddress(signer.pubKey) + + fun getBlockedRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getBlockedRelayListAddress()) + + fun getBlockedRelayListFlow(): StateFlow = getBlockedRelayListNote().flow().metadata.stateFlow + + fun getBlockedRelayList(): BlockedRelayListEvent? = getBlockedRelayListNote().event as? BlockedRelayListEvent + + suspend fun normalizeBlockedRelayListWithBackup(note: Note): Set { + val event = note.event as? BlockedRelayListEvent ?: settings.backupBlockedRelayList + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getBlockedRelayListFlow() + .map { + normalizeBlockedRelayListWithBackup(it.note) + }.onStart { emit(normalizeBlockedRelayListWithBackup(getBlockedRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(blockedRelays: List): BlockedRelayListEvent { + if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException() + val relayListForBlocked = getBlockedRelayList() + + return if (relayListForBlocked != null && relayListForBlocked.tags.isNotEmpty()) { + BlockedRelayListEvent.updateRelayList( + earlierVersion = relayListForBlocked, + relays = blockedRelays, + signer = signer, + ) + } else { + BlockedRelayListEvent.create( + relays = blockedRelays, + signer = signer, + ) + } + } + + init { + settings.backupBlockedRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Blocked Relay List Collector Start") + getBlockedRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Blocked Relay List for ${signer.pubKey}") + (it.note.event as? BlockedRelayListEvent)?.let { + settings.updateBlockedRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListDecryptionCache.kt new file mode 100644 index 0000000000..73ba129060 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent + +class BroadcastRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListState.kt new file mode 100644 index 0000000000..8cada24fe4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListState.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class BroadcastRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: BroadcastRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getBroadcastRelayListAddress() = BroadcastRelayListEvent.createAddress(signer.pubKey) + + fun getBroadcastRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getBroadcastRelayListAddress()) + + fun getBroadcastRelayListFlow(): StateFlow = getBroadcastRelayListNote().flow().metadata.stateFlow + + fun getBroadcastRelayList(): BroadcastRelayListEvent? = getBroadcastRelayListNote().event as? BroadcastRelayListEvent + + suspend fun normalizeBroadcastRelayListWithBackup(note: Note): Set { + val event = note.event as? BroadcastRelayListEvent + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getBroadcastRelayListFlow() + .map { normalizeBroadcastRelayListWithBackup(it.note) } + .onStart { emit(normalizeBroadcastRelayListWithBackup(getBroadcastRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(broadcastRelays: List): BroadcastRelayListEvent { + val relayListForBroadcast = getBroadcastRelayList() + + return if (relayListForBroadcast != null && relayListForBroadcast.tags.isNotEmpty()) { + BroadcastRelayListEvent.updateRelayList( + earlierVersion = relayListForBroadcast, + relays = broadcastRelays, + signer = signer, + ) + } else { + BroadcastRelayListEvent.create( + relays = broadcastRelays, + signer = signer, + ) + } + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionControllerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt similarity index 84% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionControllerService.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt index 0f422a7329..25f818e726 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionControllerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt @@ -18,16 +18,10 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists -interface SubscriptionControllerService { - fun start() +data class GeohashListCard( + val relays: List, +) - fun stop() - - fun invalidateFilters() - - fun destroy() - - fun printStats(tag: String) -} +val EmptyGeohashListCard = GeohashListCard(emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListDecryptionCache.kt new file mode 100644 index 0000000000..2576cbc123 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListDecryptionCache.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.geohashSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart + +class GeohashListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedGeohashes(event: GeohashListEvent) = cachedPrivateLists.mergeTagListPrecached(event).geohashSet() + + suspend fun geohashes(event: GeohashListEvent) = cachedPrivateLists.mergeTagList(event).geohashSet() + + fun fastStartValueForGeohashList(note: Note): GeohashListCard { + val noteEvent = note.event as? GeohashListEvent + return if (noteEvent != null) { + GeohashListCard(cachedGeohashes(noteEvent).toList()) + } else { + EmptyGeohashListCard + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun observeDecryptedGeohashList(note: Note): Flow = + note + .flow() + .metadata.stateFlow + .mapLatest { noteState -> + val event = noteState.note.event as? GeohashListEvent + GeohashListCard(event?.let { geohashes(it).toList() } ?: emptyList()) + }.onStart { + val event = note.event as? GeohashListEvent + if (event != null) { + val list = geohashes(event) + if (list.isNotEmpty()) { + emit(GeohashListCard(list.toList())) + } else { + emit(EmptyGeohashListCard) + } + } else { + emit(EmptyGeohashListCard) + } + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt new file mode 100644 index 0000000000..a11c07130b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class GeohashListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: GeohashListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getGeohashListAddress() = GeohashListEvent.createAddress(signer.pubKey) + + fun getGeohashListNote(): AddressableNote = cache.getOrCreateAddressableNote(getGeohashListAddress()) + + fun getGeohashListFlow(): StateFlow = getGeohashListNote().flow().metadata.stateFlow + + fun getGeohashList(): GeohashListEvent? = getGeohashListNote().event as? GeohashListEvent + + suspend fun geohashListWithBackup(note: Note): Set { + val event = note.event as? GeohashListEvent ?: settings.backupGeohashList + return event?.let { decryptionCache.geohashes(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getGeohashListFlow() + .transformLatest { noteState -> + emit(geohashListWithBackup(noteState.note)) + }.onStart { + emit(geohashListWithBackup(getGeohashListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun follow(geohashes: List): GeohashListEvent { + val geohashList = getGeohashList() + + return if (geohashList == null) { + GeohashListEvent.create(geohashes, true, signer) + } else { + GeohashListEvent.add(geohashList, geohashes, true, signer) + } + } + + suspend fun follow(geohash: String): GeohashListEvent { + val geohashList = getGeohashList() + + return if (geohashList == null) { + GeohashListEvent.create(geohash, true, signer) + } else { + GeohashListEvent.add(geohashList, geohash, true, signer) + } + } + + suspend fun unfollow(geohash: String): GeohashListEvent? { + val geohashList = getGeohashList() + + return if (geohashList != null) { + GeohashListEvent.remove(geohashList, geohash, signer) + } else { + null + } + } + + init { + settings.backupGeohashList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Geohash List Collector Start") + getGeohashListFlow().collect { noteState -> + Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}") + (noteState.note.event as? GeohashListEvent)?.let { + settings.updateGeohashListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt new file mode 100644 index 0000000000..6e73b068ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.hashtagSet + +class HashtagListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedHashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagListPrecached(event).hashtagSet() + + suspend fun hashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagList(event).hashtagSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt new file mode 100644 index 0000000000..6a73325c59 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class HashtagListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: HashtagListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey) + + fun getHashtagListNote(): AddressableNote = cache.getOrCreateAddressableNote(getHashtagListAddress()) + + fun getHashtagListFlow(): StateFlow = getHashtagListNote().flow().metadata.stateFlow + + fun getHashtagList(): HashtagListEvent? = getHashtagListNote().event as? HashtagListEvent + + suspend fun hashtagListWithBackup(note: Note): Set { + val event = note.event as? HashtagListEvent ?: settings.backupHashtagList + return event?.let { decryptionCache.hashtags(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getHashtagListFlow() + .transformLatest { noteState -> + emit(hashtagListWithBackup(noteState.note)) + }.onStart { + emit(hashtagListWithBackup(getHashtagListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + suspend fun follow(hashtags: List): HashtagListEvent { + val hashtagList = getHashtagList() + + return if (hashtagList == null) { + HashtagListEvent.Companion.create(hashtags, true, signer) + } else { + HashtagListEvent.Companion.add(hashtagList, hashtags, true, signer) + } + } + + suspend fun follow(hashtag: String): HashtagListEvent { + val hashtagList = getHashtagList() + + return if (hashtagList == null) { + HashtagListEvent.Companion.create(hashtag, true, signer) + } else { + HashtagListEvent.Companion.add(hashtagList, hashtag, true, signer) + } + } + + suspend fun unfollow(hashtag: String): HashtagListEvent? { + val hashtagList = getHashtagList() + + return if (hashtagList != null) { + HashtagListEvent.Companion.remove(hashtagList, hashtag, signer) + } else { + null + } + } + + init { + settings.backupHashtagList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Hashtag List Collector Start") + getHashtagListFlow().collect { + Log.d("AccountRegisterObservers", "Hashtag List for ${signer.pubKey}") + (it.note.event as? HashtagListEvent)?.let { + settings.updateHashtagListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListDecryptionCache.kt new file mode 100644 index 0000000000..13d1ce9d4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent + +class IndexerRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt new file mode 100644 index 0000000000..5fa08599a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class IndexerRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: IndexerRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getIndexerRelayListAddress() = IndexerRelayListEvent.createAddress(signer.pubKey) + + fun getIndexerRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getIndexerRelayListAddress()) + + fun getIndexerRelayListFlow(): StateFlow = getIndexerRelayListNote().flow().metadata.stateFlow + + fun getIndexerRelayList(): IndexerRelayListEvent? = getIndexerRelayListNote().event as? IndexerRelayListEvent + + suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set { + val event = note.event as? IndexerRelayListEvent + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getIndexerRelayListFlow() + .map { normalizeIndexerRelayListWithBackup(it.note) } + .onStart { emit(normalizeIndexerRelayListWithBackup(getIndexerRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(indexerRelays: List): IndexerRelayListEvent { + val relayListForIndexer = getIndexerRelayList() + + return if (relayListForIndexer != null && relayListForIndexer.tags.isNotEmpty()) { + IndexerRelayListEvent.updateRelayList( + earlierVersion = relayListForIndexer, + relays = indexerRelays, + signer = signer, + ) + } else { + IndexerRelayListEvent.create( + relays = indexerRelays, + signer = signer, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt new file mode 100644 index 0000000000..90314fc4a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords + +class MuteListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords() + + fun cachedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers() + + fun cachedUserIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet() + + fun cachedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords() + + fun cachedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet() + + suspend fun mutedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords() + + suspend fun mutedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers() + + suspend fun mutedUserIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet() + + suspend fun mutedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() + + suspend fun mutedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt new file mode 100644 index 0000000000..e284cc024a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.muteList + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class MuteListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: MuteListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getMuteListAddress() = MuteListEvent.createAddress(signer.pubKey) + + fun getMuteListNote() = cache.getOrCreateAddressableNote(getMuteListAddress()) + + fun getMuteListFlow(): StateFlow = getMuteListNote().flow().metadata.stateFlow + + fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent + + suspend fun muteListWithBackup(note: Note): List { + val event = note.event as? MuteListEvent ?: settings.backupMuteList + return event?.let { decryptionCache.mutedUsersAndWords(it) } ?: emptyList() + } + + val flow = + getMuteListFlow() + .map { muteListWithBackup(it.note) } + .onStart { emit(muteListWithBackup(getMuteListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun hideUser(pubkeyHex: String): MuteListEvent { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = UserTag(pubkeyHex), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = UserTag(pubkeyHex), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showUser(pubkeyHex: String): MuteListEvent? { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = UserTag(pubkeyHex), + signer = signer, + ) + } else { + null + } + } + + suspend fun hideWord(word: String): MuteListEvent { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = WordTag(word), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = WordTag(word), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showWord(word: String): MuteListEvent? { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = WordTag(word), + signer = signer, + ) + } else { + null + } + } + + init { + settings.backupMuteList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Mute List Collector Start") + getMuteListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Mute List for ${signer.pubKey}") + (it.note.event as? MuteListEvent)?.let { + settings.updateMuteList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListDecryptionCache.kt new file mode 100644 index 0000000000..2f4fff3cdb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent + +class ProxyRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListState.kt new file mode 100644 index 0000000000..2946990a98 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListState.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class ProxyRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: ProxyRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getProxyRelayListAddress() = ProxyRelayListEvent.createAddress(signer.pubKey) + + fun getProxyRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getProxyRelayListAddress()) + + fun getProxyRelayListFlow(): StateFlow = getProxyRelayListNote().flow().metadata.stateFlow + + fun getProxyRelayList(): ProxyRelayListEvent? = getProxyRelayListNote().event as? ProxyRelayListEvent + + suspend fun normalizeProxyRelayListWithBackup(note: Note): Set { + val event = note.event as? ProxyRelayListEvent + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getProxyRelayListFlow() + .map { normalizeProxyRelayListWithBackup(it.note) } + .onStart { emit(normalizeProxyRelayListWithBackup(getProxyRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(proxyRelays: List): ProxyRelayListEvent { + val relayListForProxy = getProxyRelayList() + + return if (relayListForProxy != null && relayListForProxy.tags.isNotEmpty()) { + ProxyRelayListEvent.updateRelayList( + earlierVersion = relayListForProxy, + relays = proxyRelays, + signer = signer, + ) + } else { + ProxyRelayListEvent.create( + relays = proxyRelays, + signer = signer, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt new file mode 100644 index 0000000000..c8735fbda9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.relayLists + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relaySet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart + +open class GenericRelayListCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedRelays(event: T) = cachedPrivateLists.mergeTagListPrecached(event).relaySet() + + suspend fun relays(event: T) = cachedPrivateLists.mergeTagList(event).relaySet() + + fun fastStartValueForRelayList(note: Note): RelayListCard { + val noteEvent = note.event as? T + return if (noteEvent != null) { + RelayListCard(cachedRelays(noteEvent).toList()) + } else { + EmptyRelayListCard + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun observeDecryptedRelayList(note: Note): Flow = + note + .flow() + .metadata.stateFlow + .mapLatest { noteState -> + val event = noteState.note.event as? T + RelayListCard(event?.let { relays(it).toList() } ?: emptyList()) + }.onStart { + val event = note.event as? T + if (event != null) { + val list = relays(event) + if (list.isNotEmpty()) { + emit(RelayListCard(list.toList())) + } else { + emit(EmptyRelayListCard) + } + } else { + emit(EmptyRelayListCard) + } + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/RelayListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/RelayListCard.kt new file mode 100644 index 0000000000..bf12514c4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/RelayListCard.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Immutable +data class RelayListCard( + val relays: List, +) + +val EmptyRelayListCard = RelayListCard(emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListDecryptionCache.kt new file mode 100644 index 0000000000..bf88a37526 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent + +class SearchRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt new file mode 100644 index 0000000000..3c5f2a25f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class SearchRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: SearchRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getSearchRelayListAddress() = SearchRelayListEvent.Companion.createAddress(signer.pubKey) + + fun getSearchRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getSearchRelayListAddress()) + + fun getSearchRelayListFlow(): StateFlow = getSearchRelayListNote().flow().metadata.stateFlow + + fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent + + suspend fun normalizeSearchRelayListWithBackup(note: Note): Set { + val event = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList + return event?.let { decryptionCache.relays(it) } ?: DefaultSearchRelayList + } + + val flow = + getSearchRelayListFlow() + .map { normalizeSearchRelayListWithBackup(it.note) } + .onStart { emit(normalizeSearchRelayListWithBackup(getSearchRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(searchRelays: List): SearchRelayListEvent { + val relayListForSearch = getSearchRelayList() + + return if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { + SearchRelayListEvent.Companion.updateRelayList( + earlierVersion = relayListForSearch, + relays = searchRelays, + signer = signer, + ) + } else { + SearchRelayListEvent.Companion.create( + relays = searchRelays, + signer = signer, + ) + } + } + + init { + settings.backupSearchRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Search Relay List Collector Start") + getSearchRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Search Relay List for ${signer.pubKey}") + (it.note.event as? SearchRelayListEvent)?.let { + settings.updateSearchRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListDecryptionCache.kt new file mode 100644 index 0000000000..276b47abb9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent + +class TrustedRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt new file mode 100644 index 0000000000..d5bd692354 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class TrustedRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: TrustedRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getTrustedRelayListAddress() = TrustedRelayListEvent.Companion.createAddress(signer.pubKey) + + fun getTrustedRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getTrustedRelayListAddress()) + + fun getTrustedRelayListFlow(): StateFlow = getTrustedRelayListNote().flow().metadata.stateFlow + + fun getTrustedRelayList(): TrustedRelayListEvent? = getTrustedRelayListNote().event as? TrustedRelayListEvent + + suspend fun normalizeTrustedRelayListWithBackup(note: Note): Set { + val event = note.event as? TrustedRelayListEvent ?: settings.backupTrustedRelayList + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getTrustedRelayListFlow() + .map { normalizeTrustedRelayListWithBackup(it.note) } + .onStart { emit(normalizeTrustedRelayListWithBackup(getTrustedRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent { + val relayListForTrusted = getTrustedRelayList() + + return if (relayListForTrusted != null && relayListForTrusted.tags.isNotEmpty()) { + TrustedRelayListEvent.Companion.updateRelayList( + earlierVersion = relayListForTrusted, + relays = trustedRelays, + signer = signer, + ) + } else { + TrustedRelayListEvent.Companion.create( + relays = trustedRelays, + signer = signer, + ) + } + } + + init { + settings.backupTrustedRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Trusted Relay List Collector Start") + getTrustedRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Trusted Relay List for ${signer.pubKey}") + (it.note.event as? TrustedRelayListEvent)?.let { + settings.updateTrustedRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53LiveActivities/LiveActivitiesChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53LiveActivities/LiveActivitiesChannel.kt new file mode 100644 index 0000000000..f846613e96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip53LiveActivities + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.toShortDisplay +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +@Stable +class LiveActivitiesChannel( + val address: Address, +) : Channel() { + var creator: User? = null + var info: LiveActivitiesEvent? = null + + fun address() = address + + override fun relays() = info?.allRelayUrls()?.toSet() ?: super.relays() + + fun relayHintUrl() = relays().firstOrNull() + + fun relayHintUrls() = relays().take(3) + + fun updateChannelInfo( + creator: User, + channelInfo: LiveActivitiesEvent, + ) { + this.info = channelInfo + this.creator = creator + super.updateChannelInfo() + } + + override fun toBestDisplayName(): String = info?.title() ?: toNAddr().toShortDisplay() + + fun creatorName(): String? = creator?.toBestDisplayName() + + fun summary(): String? = info?.summary() + + fun profilePicture(): String? = info?.image()?.ifBlank { null } + + fun anyNameStartsWith(prefix: String): Boolean = + info?.title()?.contains(prefix, true) == true || + info?.summary()?.contains(prefix, true) == true + + fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrls()) + + fun toATag() = ATag(address, relayHintUrl()) + + fun toNostrUri() = "nostr:${toNAddr()}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt new file mode 100644 index 0000000000..cb534fa395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip56Reports + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType + +class ReportAction { + companion object { + suspend fun report( + user: User, + type: ReportType, + by: User, + signer: NostrSigner, + ): ReportEvent? { + if (user.hasReport(by, type)) { + // has already reported this note + return null + } + + val template = ReportEvent.build(user.pubkeyHex, type) + + return signer.sign(template) + } + + suspend fun report( + note: Note, + type: ReportType, + content: String = "", + by: User, + signer: NostrSigner, + ): ReportEvent? { + if (note.hasReport(by, type)) { + // has already reported this note + return null + } + + return note.event?.let { + signer.sign(ReportEvent.build(it, type)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt new file mode 100644 index 0000000000..9df03f4419 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip65RelayList + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class Nip65RelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey) + + fun getNIP65RelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress()) + + fun getNIP65RelayListFlow(): StateFlow = getNIP65RelayListNote().flow().metadata.stateFlow + + fun getNIP65RelayList(): AdvertisedRelayListEvent? = getNIP65RelayListNote().event as? AdvertisedRelayListEvent + + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + } + + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.readRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + } + + fun normalizeNIP65AllRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.relays()?.map { it.relayUrl }?.toSet() ?: Constants.eventFinderRelays + } + + val outboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val allFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65AllRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65AllRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(relays: List): AdvertisedRelayListEvent { + val nip65RelayList = getNIP65RelayList() + + return if (nip65RelayList != null) { + AdvertisedRelayListEvent.replaceRelayListWith( + earlierVersion = nip65RelayList, + newRelays = relays, + signer = signer, + ) + } else { + AdvertisedRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + ) + } + } + + init { + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}") + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt new file mode 100644 index 0000000000..8f37e8cf34 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip65RelayList + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class OutboxRelaySetState( + usersToLoad: MutableStateFlow>, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.Companion.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow> = + combine(flows) { relayListNotes: Array -> + relayListNotes.mapNotNull { + (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() + } + }.map { + it.flatten().toSet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + usersToLoad + .transformLatest { followList -> + val flows: List> = allRelayListFlows(followList) + val relayListFlows = combineAllFlows(flows) + emitAll(relayListFlows) + }.onStart { + emit( + usersToLoad.value + .mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm() + }.flatten() + .toSet(), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow + .map { relayList -> + relayList.map { it.url }.toSet() + }.onStart { + emit( + usersToLoad.value + .mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet() + }.flatten() + .toSet(), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListDecryptionCache.kt new file mode 100644 index 0000000000..2ea70cb3a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListDecryptionCache.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip72Communities + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.communities +import com.vitorpamplona.quartz.nip72ModCommunities.follow.communityIdSet +import com.vitorpamplona.quartz.nip72ModCommunities.follow.communityIds + +class CommunityListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedCommunityIds(event: CommunityListEvent) = cachedPrivateLists.mergeTagListPrecached(event).communityIds() + + fun cachedCommunityIdSet(event: CommunityListEvent) = cachedPrivateLists.mergeTagListPrecached(event).communityIdSet() + + fun cachedCommunities(event: CommunityListEvent) = cachedPrivateLists.mergeTagListPrecached(event).communities() + + suspend fun communityIds(event: CommunityListEvent) = cachedPrivateLists.mergeTagList(event).communityIds() + + suspend fun communityIdSet(event: CommunityListEvent) = cachedPrivateLists.mergeTagList(event).communityIdSet() + + suspend fun communities(event: CommunityListEvent) = cachedPrivateLists.mergeTagList(event).communities() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt new file mode 100644 index 0000000000..9406632f6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip72Communities + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class CommunityListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: CommunityListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getCommunityListAddress() = CommunityListEvent.createAddress(signer.pubKey) + + fun getCommunityListNote(): AddressableNote = cache.getOrCreateAddressableNote(getCommunityListAddress()) + + fun getCommunityListFlow(): StateFlow = getCommunityListNote().flow().metadata.stateFlow + + fun getCommunityList(): CommunityListEvent? = getCommunityListNote().event as? CommunityListEvent + + suspend fun communityListWithBackup(note: Note): Set { + val event = note.event as? CommunityListEvent ?: settings.backupCommunityList + return event?.let { decryptionCache.communities(it).toSet() } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getCommunityListFlow() + .transformLatest { noteState -> + emit(communityListWithBackup(noteState.note)) + }.onStart { + emit(communityListWithBackup(getCommunityListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow + .map { hint -> + hint.mapTo(mutableSetOf()) { it.address.toValue() } + }.onStart { + emit(flow.value.mapTo(mutableSetOf()) { it.address.toValue() }) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun follow(communities: List): CommunityListEvent { + val communityList = getCommunityList() + + val communityTags = + communities.mapNotNull { community -> + if (community.address.kind == CommunityDefinitionEvent.KIND) { + CommunityTag(community.address, community.relayHintUrl()) + } else { + null + } + } + + return if (communityList == null) { + CommunityListEvent.create(communityTags, true, signer) + } else { + CommunityListEvent.add(communityList, communityTags, true, signer) + } + } + + suspend fun follow(community: AddressableNote): CommunityListEvent? { + val communityList = getCommunityList() + if (community.address.kind != CommunityDefinitionEvent.KIND) return communityList + + return if (communityList == null) { + CommunityListEvent.create( + CommunityTag(community.address, community.relayHintUrl()), + true, + signer, + ) + } else { + CommunityListEvent.add(communityList, CommunityTag(community.address, community.relayHintUrl()), true, signer) + } + } + + suspend fun unfollow(community: AddressableNote): CommunityListEvent? { + val communityList = getCommunityList() + if (community.address.kind != CommunityDefinitionEvent.KIND) return communityList + + return if (communityList != null) { + CommunityListEvent.remove(communityList, CommunityTag(community.address, community.relayHintUrl()), signer) + } else { + null + } + } + + init { + settings.backupCommunityList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Community List Collector Start") + getCommunityListFlow().collect { + Log.d("AccountRegisterObservers", "Community List for ${signer.pubKey}") + (it.note.event as? CommunityListEvent)?.let { + settings.updateCommunityListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt new file mode 100644 index 0000000000..8a65a3de7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip78AppSpecific + +import android.util.Log +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +class AppSpecificState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + companion object { + const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" + } + + fun getAppSpecificDataAddress() = AppSpecificDataEvent.createAddress(signer.pubKey, APP_SPECIFIC_DATA_D_TAG) + + fun getAppSpecificDataNote() = cache.getOrCreateAddressableNote(getAppSpecificDataAddress()) + + fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow + + suspend fun saveNewAppSpecificData(): AppSpecificDataEvent { + val toInternal = settings.syncedSettings.toInternal() + return AppSpecificDataEvent.create( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = signer.nip44Encrypt(JsonMapper.mapper.writeValueAsString(toInternal), signer.pubKey), + otherTags = emptyArray(), + signer = signer, + ) + } + + init { + settings.backupAppSpecificData?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + try { + val decrypted = signer.decrypt(event.content, event.pubKey) + val syncedSettings = JsonMapper.mapper.readValue(decrypted) + settings.syncedSettings.updateFrom(syncedSettings) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value", e) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") + getAppSpecificDataFlow().collect { + Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}") + (it.note.event as? AppSpecificDataEvent)?.let { + val decrypted = signer.decrypt(it.content, it.pubKey) + try { + val syncedSettings = JsonMapper.mapper.readValue(decrypted) + settings.updateAppSpecificData(it, syncedSettings) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt new file mode 100644 index 0000000000..05af740395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip96FileStorage + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class FileStorageServerListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getFileServersAddress() = FileServersEvent.createAddress(signer.pubKey) + + fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getFileServersAddress()) + + fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow + + fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? FileServersEvent + return event?.servers() ?: emptyList() + } + + val flow = + getFileServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(getFileServersNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveFileServersList(servers: List): FileServersEvent { + val serverList = getFileServersList() + + val template = + if (serverList != null && serverList.tags.isNotEmpty()) { + FileServersEvent.replaceServers(serverList, servers) + } else { + FileServersEvent.build(servers) + } + + return signer.sign(template) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt new file mode 100644 index 0000000000..5d8557d35e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nipB7Blossom + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class BlossomServerListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey) + + fun getBlossomServersNote(): AddressableNote = cache.getOrCreateAddressableNote(getBlossomServersAddress()) + + fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow + + fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? BlossomServersEvent + return event?.servers() ?: emptyList() + } + + val flow = + getBlossomServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(getBlossomServersNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { + val serverList = getBlossomServersList() + + return if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + relays = servers, + signer = signer, + ) + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) + } + } + + suspend fun createBlossomUploadAuth( + hash: HexKey, + size: Long, + alt: String, + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + + suspend fun createBlossomDeleteAuth( + hash: HexKey, + alt: String, + ): BlossomAuthorizationEvent? = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt index 3b142b654b..d7d3831988 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -51,10 +52,8 @@ class LatestByKindAndAuthor( if ((kind in 10000..19999) || (kind in 30000..39999)) { LocalCache.addressables .maxOrNullOf( - filter = { idHex: String, note: AddressableNote -> - note.event?.let { - it.kind == kind && it.pubKey == pubkey - } == true + filter = { address: Address, note: AddressableNote -> + address.kind == kind && address.pubKeyHex == pubkey }, comparator = CreatedAtComparatorAddresses, )?.event as? T diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 0000000000..d847670418 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.quartz.utils.LargeCache +import java.io.File + +class AccountPreferenceStores( + val rootFilesDir: () -> File, +) { + companion object { + val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList") + val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList") + val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList") + val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList") + + val localRelayServers = stringPreferencesKey("localRelayServers") + val defaultFileServer = stringPreferencesKey("defaultFileServer") + + val latestUserMetadata = stringPreferencesKey("latestUserMetadata") + val latestContactList = stringPreferencesKey("latestContactList") + val latestDMRelayList = stringPreferencesKey("latestDMRelayList") + val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList") + val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList") + val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList") + val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList") + val latestMuteList = stringPreferencesKey("latestMuteList") + val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList") + val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData") + val latestChannelList = stringPreferencesKey("latestChannelList") + val latestCommunityList = stringPreferencesKey("latestCommunityList") + val latestHashtagList = stringPreferencesKey("latestHashtagList") + val latestGeohashList = stringPreferencesKey("latestGeohashList") + val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList") + + val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog") + val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog") + val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog") + + val torSettings = stringPreferencesKey("tor_settings") + + val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion") + } + + private val storeCache = LargeCache>() + + fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences") + + private fun getDataStore(npub: String): DataStore = + storeCache.getOrCreate(npub) { + PreferenceDataStoreFactory.create( + produceFile = { file(npub) }, + ) + } + + fun removeAccount(npub: String) { + file(npub).delete() + storeCache.remove(npub) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt new file mode 100644 index 0000000000..07f561976e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.utils.LargeCache +import java.io.File + +class AccountSecretsEncryptedStores( + val rootFilesDir: () -> File, +) { + companion object Companion { + val encryption = KeyStoreEncryption() + val key = stringPreferencesKey("privKey") + val nwc = stringPreferencesKey("nwc") + } + + private val storeCache = LargeCache() + + fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") + + private fun getDataStore(npub: String): EncryptedDataStore = + storeCache.getOrCreate(npub) { + EncryptedDataStore( + PreferenceDataStoreFactory.create( + produceFile = { file(npub) }, + ), + encryption, + ) + } + + suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key) + + suspend fun savePrivateKey( + npub: String, + value: HexKey, + ) { + getDataStore(npub).save(key, value) + } + + suspend fun nwc(npub: String): UpdatablePropertyFlow = + getDataStore(npub).getProperty( + key = nwc, + parser = Nip47WalletConnect.Nip47URI::parser, + serializer = Nip47WalletConnect.Nip47URI::serializer, + ) + + fun removeAccount(npub: String) { + file(npub).delete() + storeCache.remove(npub) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt new file mode 100644 index 0000000000..1e5bc5fbb1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URI.Companion.parser +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +suspend fun DataStore.getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, +): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + val value = prefs[key] + if (value != null) { + parser(value) + } else { + null + } + }, + update = { newValue -> + if (newValue != null) { + val serialized = serializer(newValue) + if (serialized.isNotBlank()) { + edit { prefs -> + prefs[key] = serialized + } + } else { + edit { prefs -> + prefs.remove(key) + } + } + } else { + edit { prefs -> + prefs.remove(key) + } + } + }, + scope = scope, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt new file mode 100644 index 0000000000..b14d65f288 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import java.io.IOException +import java.util.Base64 + +class EncryptedDataStore( + private val store: DataStore, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) { + private fun decode(str: String): ByteArray = Base64.getDecoder().decode(str) + + private fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray())) + + private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString() + + suspend fun remove(key: Preferences.Key) { + store.edit { prefs -> + prefs.remove(key) + } + } + + suspend fun save( + key: Preferences.Key, + value: String, + ) { + store.edit { prefs -> + prefs[key] = encrypt(value) + } + } + + suspend fun get(key: Preferences.Key): String? = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.firstOrNull() + ?.get(key) + ?.let { decrypt(it) } + + suspend fun getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, + ): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + val value = prefs[key] + if (value != null) { + val decrypted = decrypt(value) + if (decrypted.isNotBlank()) { + parser(decrypted) + } else { + null + } + } else { + null + } + }, + update = { newValue -> + if (newValue != null) { + val serialized = serializer(newValue) + if (serialized.isNotBlank()) { + save(key, serialized) + } else { + remove(key) + } + } else { + remove(key) + } + }, + scope = scope, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt new file mode 100644 index 0000000000..eca8de6873 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec + +class KeyStoreEncryption { + companion object { + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + private const val KEY_ALIAS = "AMETHYST_AES_KEY" + } + + private val cipher = Cipher.getInstance(TRANSFORMATION) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + private fun getKey(): SecretKey { + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + private fun createKeyStrongBoxIfAvailable(): SecretKey? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + val keyParams = + KeyGenParameterSpec + .Builder(KEY_ALIAS, PURPOSE) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .setIsStrongBoxBacked(true) + .build() + + val generator = KeyGenerator.getInstance(ALGORITHM) + generator.init(keyParams) + generator.generateKey() + } catch (_: StrongBoxUnavailableException) { + null + } + } else { + null + } + + private fun createKeyRegular(): SecretKey { + val keyParams = + KeyGenParameterSpec + .Builder(KEY_ALIAS, PURPOSE) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .build() + + val generator = KeyGenerator.getInstance(ALGORITHM) + generator.init(keyParams) + return generator.generateKey() + } + + private fun createKey(): SecretKey = createKeyStrongBoxIfAvailable() ?: createKeyRegular() + + fun encrypt(bytes: ByteArray): ByteArray { + // Initializes the cipher in encrypt mode and encrypts data + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(bytes) + return iv + encrypted + } + + fun decrypt(bytes: ByteArray): ByteArray? { + // Extracts IV and decrypts the data + val iv = bytes.copyOfRange(0, cipher.blockSize) + val data = bytes.copyOfRange(cipher.blockSize, bytes.size) + cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + return cipher.doFinal(data) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt index 49db64fce5..af96b24f56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt @@ -18,21 +18,26 @@ * 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.relays.kind3 +package com.vitorpamplona.amethyst.model.preferences -import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn -@Immutable -data class Kind3BasicRelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, - val relayStat: RelayStat, - val paidRelay: Boolean = false, +class UpdatablePropertyFlow( + flow: Flow, + val update: suspend (T?) -> Unit, + val scope: CoroutineScope, ) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) + val stateFlow = + flow + .flowOn(Dispatchers.Default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = null, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyState.kt new file mode 100644 index 0000000000..dcbee7e43d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyState.kt @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.privacyOptions + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +class PrivacyState( + val settings: AccountSettings, +) { + fun shouldUseTorForImageDownload(url: String) = + shouldUseTorFor( + url, + settings.torSettings.torType.value, + settings.torSettings.imagesViaTor.value, + ) + + fun shouldUseTorFor( + url: String, + torType: TorType, + imagesViaTor: Boolean, + ) = when (torType) { + TorType.OFF -> false + TorType.INTERNAL -> shouldUseTor(url, imagesViaTor) + TorType.EXTERNAL -> shouldUseTor(url, imagesViaTor) + } + + private fun shouldUseTor( + normalizedUrl: String, + final: Boolean, + ): Boolean = + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + false + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { + true + } else { + final + } + + fun shouldUseTorForVideoDownload() = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> settings.torSettings.videosViaTor.value + TorType.EXTERNAL -> settings.torSettings.videosViaTor.value + } + + fun shouldUseTorForVideoDownload(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) + } + + fun shouldUseTorForPreviewUrl(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) + } + + fun shouldUseTorForTrustedRelays() = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value + TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value + } + + private fun checkLocalHostOnionAndThen( + url: String, + final: Boolean, + ): Boolean = checkLocalHostOnionAndThen(url, settings.torSettings.onionRelaysViaTor.value, final) + + private fun checkLocalHostOnionAndThen( + normalizedUrl: String, + isOnionRelaysActive: Boolean, + final: Boolean, + ): Boolean = + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + false + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { + isOnionRelaysActive + } else { + final + } + + fun shouldUseTorForMoneyOperations(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) + } + + fun shouldUseTorForNIP05(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) + } + + fun shouldUseTorForUploads(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt index e0e03a1b33..969e84bb4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt @@ -18,67 +18,83 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.model.privateChats import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NotesGatherer +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow @Stable -class Chatroom { - var authors: Set = setOf() - var roomMessages: Set = setOf() - var subject: String? = null +class Chatroom : NotesGatherer { + var activeSenders: Set = setOf() + var messages: Set = setOf() + var subject = MutableStateFlow(null) var subjectCreatedAt: Long? = null + var ownerSentMessage: Boolean = false + var lastMessage: Note? = null + + override fun removeNote(note: Note) { + removeMessageSync(note) + } @Synchronized - fun addMessageSync(msg: Note) { - checkNotInMainThread() - - if (msg !in roomMessages) { - roomMessages = roomMessages + msg + fun addMessageSync(msg: Note): Boolean { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) msg.author?.let { author -> - if (author !in authors) { - authors += author + if (author !in activeSenders) { + activeSenders += author } } + val createdAt = msg.createdAt() ?: 0 + if (createdAt > (lastMessage?.createdAt() ?: 0)) { + lastMessage = msg + } + val newSubject = msg.event?.subject() if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { - subject = newSubject + subject.tryEmit(newSubject) subjectCreatedAt = msg.createdAt() } + return true } + return false } @Synchronized - fun removeMessageSync(msg: Note) { - checkNotInMainThread() + fun removeMessageSync(msg: Note): Boolean { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) - if (msg in roomMessages) { - roomMessages = roomMessages - msg - - roomMessages + messages .filter { it.event?.subject() != null } .sortedBy { it.createdAt() } .lastOrNull() ?.let { - subject = it.event?.subject() + subject.tryEmit(it.event?.subject()) subjectCreatedAt = it.createdAt() } + return true } + return false } - fun senderIntersects(keySet: Set): Boolean = authors.any { it.pubkeyHex in keySet } + fun senderIntersects(keySet: Set): Boolean = activeSenders.any { it.pubkeyHex in keySet } fun pruneMessagesToTheLatestOnly(): Set { - val sorted = roomMessages.sortedWith(DefaultFeedOrder) + val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { @@ -89,8 +105,8 @@ class Chatroom { sorted.take(1).toSet() } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent } - val toRemove = roomMessages.minus(toKeep) - roomMessages = toKeep + val toRemove = messages.minus(toKeep) + messages = toKeep return toRemove } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.kt new file mode 100644 index 0000000000..93bcdc372b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.privateChats + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.collections.immutable.persistentSetOf + +class ChatroomList( + val ownerPubKey: HexKey, +) { + var rooms = LargeCache() + private set + + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() } + + fun getOrCreatePrivateChatroom(user: User): Chatroom { + val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) + return getOrCreatePrivateChatroom(key) + } + + fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = getOrCreatePrivateChatroomSync(key) + + fun addMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg !in privateChatroom.messages) { + privateChatroom.addMessageSync(msg) + } + } + + fun addMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg !in privateChatroom.messages) { + privateChatroom.addMessageSync(msg) + if (msg.author?.pubkeyHex == ownerPubKey) { + privateChatroom.ownerSentMessage = true + } + } + } + + fun removeMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg in privateChatroom.messages) { + privateChatroom.removeMessageSync(msg) + } + } + + fun removeMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg in privateChatroom.messages) { + privateChatroom.removeMessageSync(msg) + } + } + + fun hasSentMessagesTo(key: ChatroomKey?): Boolean { + if (key == null) return false + return rooms.get(key)?.ownerSentMessage == true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt new file mode 100644 index 0000000000..bdcb94ed6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn +import kotlin.collections.map + +class MergedFollowListsState( + val kind3List: FollowListState, + val hashtagList: HashtagListState, + val geohashList: GeohashListState, + val communityList: CommunityListState, + val scope: CoroutineScope, +) { + fun mergeLists( + kind3: FollowListState.Kind3Follows, + hashtags: Set, + geohashes: Set, + community: Set, + ): FollowListState.Kind3Follows = + FollowListState.Kind3Follows( + kind3.authors, + kind3.authorsPlusMe, + kind3.hashtags + hashtags, + kind3.geotags + geohashes, + kind3.communities + community.map { it.address.toValue() }, + ) + + val flow: StateFlow = + combine( + kind3List.flow, + hashtagList.flow, + geohashList.flow, + communityList.flow, + ) { kind3, hashtag, geohash, community -> + mergeLists(kind3, hashtag, geohash, community) + }.onStart { + emit( + mergeLists( + kind3List.flow.value, + hashtagList.flow.value, + geohashList.flow.value, + communityList.flow.value, + ), + ) + }.sample(200) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + kind3List.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt new file mode 100644 index 0000000000..2b0fa4a56f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedFollowPlusMineRelayListsState( + val followsOutboxOrProxyRelayList: FollowListOutboxOrProxyRelays, + val nip65RelayList: Nip65RelayListState, + val privateOutboxRelayList: PrivateStorageRelayListState, + val localRelayList: LocalRelayListState, + val broadcastRelayList: BroadcastRelayListState, + val indexerRelayList: IndexerRelayListState, + val scope: CoroutineScope, +) { + fun mergeLists(lists: Array>): Set = lists.reduce { acc, set -> acc + set } + + val flow: StateFlow> = + combine( + listOf( + followsOutboxOrProxyRelayList.flow, + nip65RelayList.outboxFlow, + nip65RelayList.inboxFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + broadcastRelayList.flow, + indexerRelayList.flow, + ), + ::mergeLists, + ).onStart { + emit( + mergeLists( + arrayOf( + followsOutboxOrProxyRelayList.flow.value, + nip65RelayList.outboxFlow.value, + nip65RelayList.inboxFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + broadcastRelayList.flow.value, + indexerRelayList.flow.value, + ), + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + mergeLists( + arrayOf( + followsOutboxOrProxyRelayList.flow.value, + nip65RelayList.outboxFlow.value, + nip65RelayList.inboxFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + broadcastRelayList.flow.value, + indexerRelayList.flow.value, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt new file mode 100644 index 0000000000..578e912ed7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState +import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import org.czeal.rfc3986.URIReference + +class MergedServerListState( + val fileServers: FileStorageServerListState, + val blossomServers: BlossomServerListState, + val scope: CoroutineScope, +) { + fun host(url: String): String = + try { + URIReference.parse(url).host.value + } catch (e: Exception) { + url + } + + fun mergeServerList( + nip96: List?, + blossom: List?, + ): List { + val nip96servers = nip96?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() + val blossomServers = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() + + val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } + + return result + ServerName("NIP95", "", ServerType.NIP95) + } + + val liveServerList: StateFlow> = + combine(fileServers.flow, blossomServers.flow) { nip96s, blossoms -> + mergeServerList(nip96s, blossoms) + }.onStart { + emit(mergeServerList(fileServers.flow.value, blossomServers.flow.value)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + DEFAULT_MEDIA_SERVERS, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt new file mode 100644 index 0000000000..743481c340 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class TrustedRelayListsState( + val nip65RelayList: Nip65RelayListState, + val privateOutboxRelayList: PrivateStorageRelayListState, + val localRelayList: LocalRelayListState, + val dmRelayList: DmRelayListState, + val searchRelayListState: SearchRelayListState, + val trustedRelayList: TrustedRelayListState, + val broadcastRelayList: BroadcastRelayListState, + val scope: CoroutineScope, +) { + fun mergeLists(lists: Array>): Set = lists.reduce { acc, set -> acc + set } + + val flow: StateFlow> = + combine( + listOf( + nip65RelayList.allFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + dmRelayList.flow, + searchRelayListState.flow, + trustedRelayList.flow, + broadcastRelayList.flow, + ), + ::mergeLists, + ).onStart { + emit( + mergeLists( + arrayOf( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + trustedRelayList.flow.value, + broadcastRelayList.flow.value, + ), + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + mergeLists( + arrayOf( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + trustedRelayList.flow.value, + broadcastRelayList.flow.value, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt new file mode 100644 index 0000000000..d4f4e44fea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class CommunityRelayLoader { + companion object { + fun communitiesPerRelay( + communityNotes: Array, + cache: LocalCache, + ): Map> = + mapOfSet { + communityNotes.forEach { communityNote -> + val relays = + (communityNote.note.event as? CommunityDefinitionEvent) + ?.relayUrls() + ?.ifEmpty { null } + ?: cache.relayHints.hintsForAddress(communityNote.note.idHex) + + relays.forEach { + add(it, communityNote.note.idHex) + } + } + } + + fun communitiesPerRelaySnapshot( + communities: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + communities + .mapNotNull { addressId -> + cache + .checkGetOrCreateAddressableNote(addressId) + ?.flow() + ?.metadata + ?.stateFlow + ?.value + }.toTypedArray() + return transformation(communitiesPerRelay(noteMetadata, cache)) + } + + fun toCommunitiesPerRelayFlow( + communities: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + communities.mapNotNull { addressId -> + cache + .checkGetOrCreateAddressableNote(addressId) + ?.flow() + ?.metadata + ?.stateFlow + } + + return combine(noteMetadataFlows) { communityNotes -> + transformation(communitiesPerRelay(communityNotes, cache)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt new file mode 100644 index 0000000000..2ab2797652 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache + +class FeedDecryptionCaches( + val peopleListCache: PeopleListDecryptionCache, + val muteListCache: MuteListDecryptionCache, + val communityListCache: CommunityListDecryptionCache, + val hashtagCache: HashtagListDecryptionCache, + val geohashCache: GeohashListDecryptionCache, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt new file mode 100644 index 0000000000..a825fed969 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS +import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class FeedTopNavFilterState( + val feedFilterListName: MutableStateFlow, + val allFollows: StateFlow, + val locationFlow: StateFlow, + val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, + val caches: FeedDecryptionCaches, + val signer: NostrSigner, + val scope: CoroutineScope, +) { + fun loadFlowsFor(listName: String): IFeedFlowsType = + when (listName) { + GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays, proxyRelays) + ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) + AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) + else -> { + val note = LocalCache.checkGetOrCreateAddressableNote(listName) + if (note != null) { + NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays, proxyRelays, caches) + } else { + UnknownFeedFlow(listName) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow = + feedFilterListName + .transformLatest { listName -> + emitAll(loadFlowsFor(listName).flow()) + }.onStart { + loadFlowsFor(feedFilterListName.value).startValue(this) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + loadFlowsFor(feedFilterListName.value).startValue(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt index 99c3191863..150fa4c992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt @@ -18,16 +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.relays +package com.vitorpamplona.amethyst.model.topNavFeeds -import com.vitorpamplona.ammolite.relays.NostrClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector -sealed class RelayServiceStatus { - data class Active( - val client: NostrClient, - ) : RelayServiceStatus() +interface IFeedFlowsType { + fun flow(): Flow - object Off : RelayServiceStatus() + fun startValue(): IFeedTopNavFilter - object Connecting : RelayServiceStatus() + suspend fun startValue(collector: FlowCollector) } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt similarity index 72% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt index 0071534387..2ec3d0a43d 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt @@ -18,23 +18,19 @@ * 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.ammolite.relays.filters +package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow -interface IPerRelayFilter { - fun toRelay(forRelay: String): Filter +interface IFeedTopNavFilter { + fun matchAuthor(pubkey: HexKey): Boolean - fun toJson(forRelay: String): String + fun match(noteEvent: Event): Boolean - fun match( - event: Event, - forRelay: String, - ): Boolean + fun toPerRelayFlow(cache: LocalCache): Flow - fun toDebugJson(): String - - // This only exists because some relays confuse empty lists with null lists - fun isValidFor(url: String): Boolean + fun startValue(cache: LocalCache): IFeedTopNavPerRelayFilterSet } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt similarity index 87% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt index 48d3cd43b0..4cbff4957a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt @@ -18,8 +18,6 @@ * 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.quartz.nip02FollowList.tags +package com.vitorpamplona.amethyst.model.topNavFeeds -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag - -typealias AddressFollowTag = ATag +interface IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..5f922fbeb5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +interface IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt new file mode 100644 index 0000000000..fb860ae395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedTopFeedAuthorListsState( + val homeNavFilter: StateFlow, + val videoNavFilter: StateFlow, + val discoveryNavFilter: StateFlow, + val notificationNavFilter: StateFlow, + val scope: CoroutineScope, +) { + fun authorList(navFilter: IFeedTopNavPerRelayFilterSet): Map?> = + when (navFilter) { + is AllCommunitiesTopNavPerRelayFilterSet -> emptyMap() + is AllFollowsTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is AuthorsTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is GlobalTopNavPerRelayFilterSet -> emptyMap() + is HashtagTopNavPerRelayFilterSet -> emptyMap() + is LocationTopNavPerRelayFilterSet -> emptyMap() + is MutedAuthorsTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is SingleCommunityTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + else -> emptyMap() + } + + fun mergeLists( + homeNavFilter: IFeedTopNavPerRelayFilterSet, + videoNavFilter: IFeedTopNavPerRelayFilterSet, + discoveryNavFilter: IFeedTopNavPerRelayFilterSet, + notificationNavFilter: IFeedTopNavPerRelayFilterSet, + ): Map> = + mapOfSet { + authorList(homeNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(videoNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(discoveryNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(notificationNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + } + + val flow: StateFlow>> = + combine( + homeNavFilter, + videoNavFilter, + discoveryNavFilter, + notificationNavFilter, + ::mergeLists, + ).onStart { + emit( + mergeLists( + homeNavFilter.value, + videoNavFilter.value, + discoveryNavFilter.value, + notificationNavFilter.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt new file mode 100644 index 0000000000..257fdccf03 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class OutboxLoaderState( + topNavFilter: StateFlow, + cache: LocalCache, + scope: CoroutineScope, +) { + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow = + topNavFilter + .transformLatest { filterSettings -> + emitAll(filterSettings.toPerRelayFlow(cache)) + }.onStart { + emit(topNavFilter.value.startValue(cache)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + UnknownTopNavPerRelayFilterSet, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt new file mode 100644 index 0000000000..26bee7d8e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class OutboxRelayLoader { + companion object { + private fun authorsPerRelay( + outboxRelayNotes: Array, + cache: LocalCache, + ): Map> = + mapOfSet { + outboxRelayNotes.forEach { outboxNote -> + val note = outboxNote.note + + val authorHex = + if (note is AddressableNote) { + note.address.pubKeyHex + } else { + note.author?.pubkeyHex + } + + if (authorHex != null) { + val relays = + (outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.ifEmpty { null } + ?: cache.relayHints.hintsForKey(authorHex) + + relays.forEach { + add(it, authorHex) + } + } + } + } + + fun authorsPerRelaySnapshot( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + authors + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + return transformation(authorsPerRelay(noteMetadata, cache)) + } + + fun toAuthorsPerRelayFlow( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + authors.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + return combine(noteMetadataFlows) { outboxRelays -> + transformation(authorsPerRelay(outboxRelays, cache)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt new file mode 100644 index 0000000000..38c4911e50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlin.collections.associateWith + +/** + * This is a big OR filter on all fields. + */ +@Immutable +class AllFollowsByOutboxTopNavFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, + val defaultRelays: StateFlow>, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else if (noteEvent is CommentEvent) { + // ignore follows and checks only the root scope + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (hashtagScopes != null && noteEvent.isTaggedScopes(hashtagScopes)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (geotagScopes != null && noteEvent.isTaggedScopes(geotagScopes)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else { + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = + if (authors != null) { + OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + } else { + MutableStateFlow(emptyMap()) + } + val communitiesPerRelay = + if (communities != null) { + CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it } + } else { + MutableStateFlow(emptyMap()) + } + + return combine(authorsPerRelay, communitiesPerRelay, defaultRelays, blockedRelays) { perRelayAuthors, perRelayCommunities, default, blockedRelays -> + val allRelays = (perRelayAuthors.keys + perRelayCommunities.keys).filter { it !in blockedRelays }.ifEmpty { default } + + AllFollowsTopNavPerRelayFilterSet( + allRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = perRelayAuthors[it], + hashtags = hashtags, + geotags = geotags, + communities = perRelayCommunities[it], + ) + }, + ) + } + } + + override fun startValue(cache: LocalCache): AllFollowsTopNavPerRelayFilterSet { + val authorsPerRelay = + if (authors != null) { + OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + } else { + emptyMap() + } + val communitiesPerRelay = + if (communities != null) { + CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it } + } else { + emptyMap() + } + + val allRelays = (authorsPerRelay.keys + communitiesPerRelay.keys).filter { it !in blockedRelays.value }.ifEmpty { defaultRelays.value } + + return AllFollowsTopNavPerRelayFilterSet( + allRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = authorsPerRelay[it], + hashtags = hashtags, + geotags = geotags, + communities = communitiesPerRelay[it], + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt new file mode 100644 index 0000000000..348c101953 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.collections.associateWith + +/** + * This is a big OR filter on all fields. + */ +@Immutable +class AllFollowsByProxyTopNavFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, + val proxyRelays: Set, +) : IFeedTopNavFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else if (noteEvent is CommentEvent) { + // ignore follows and checks only the root scope + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (hashtagScopes != null && noteEvent.isTaggedScopes(hashtagScopes)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (geotagScopes != null && noteEvent.isTaggedScopes(geotagScopes)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else { + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } + + // forces the use of the Proxy on all connections, replacing the outbox model. + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + AllFollowsTopNavPerRelayFilterSet( + proxyRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = authors, + hashtags = hashtags, + geotags = geotags, + communities = communities, + ) + }, + ), + ) + + override fun startValue(cache: LocalCache): AllFollowsTopNavPerRelayFilterSet { + // forces the use of the Proxy on all connections, replacing the outbox model. + return AllFollowsTopNavPerRelayFilterSet( + proxyRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = authors, + hashtags = hashtags, + geotags = geotags, + communities = communities, + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt new file mode 100644 index 0000000000..f233cea57d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class AllFollowsFeedFlow( + val allFollows: StateFlow, + val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + fun convert( + kind3: FollowListState.Kind3Follows?, + proxyRelays: Set, + ): IFeedTopNavFilter = + if (kind3 != null) { + if (proxyRelays.isEmpty()) { + AllFollowsByOutboxTopNavFilter( + authors = kind3.authors, + hashtags = kind3.hashtags, + geotags = kind3.geotags, + communities = kind3.communities, + defaultRelays = followsRelays, + blockedRelays = blockedRelays, + ) + } else { + AllFollowsByProxyTopNavFilter( + authors = kind3.authors, + hashtags = kind3.hashtags, + geotags = kind3.geotags, + communities = kind3.communities, + proxyRelays = proxyRelays, + ) + } + } else { + AllFollowsByOutboxTopNavFilter( + authors = emptySet(), + defaultRelays = followsRelays, + blockedRelays = blockedRelays, + ) + } + + override fun flow() = combine(allFollows, proxyRelays, ::convert) + + override fun startValue(): IFeedTopNavFilter = convert(allFollows.value, proxyRelays.value) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..af5595c048 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +/** + * This is a big OR filter. + */ +@Immutable +class AllFollowsTopNavPerRelayFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, +) : IFeedTopNavPerRelayFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..032b4590cf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AllFollowsTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt new file mode 100644 index 0000000000..028931a18e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import com.fonfon.kgeohash.GeoHash + +fun compute50kmLine(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.add(geoHash.toString()) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.westernNeighbour + hashes.add(currentGeoHash.toString()) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.easternNeighbour + hashes.add(currentGeoHash.toString()) + } + + return hashes +} + +fun compute50kmRange(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.addAll(compute50kmLine(geoHash)) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.northernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.southernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + return hashes +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt new file mode 100644 index 0000000000..a0c66edc0d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class AroundMeFeedFlow( + val location: StateFlow, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + fun convert( + result: LocationState.LocationResult, + outboxRelays: Set, + proxyRelays: Set, + ): LocationTopNavFilter = + if (result is LocationState.LocationResult.Success) { + // 2 neighbors deep = 25x25km + LocationTopNavFilter( + geotags = compute50kmRange(result.geoHash).toSet(), + relayList = proxyRelays.ifEmpty { outboxRelays }, + ) + } else { + // empty feed until we have a successful geohash + LocationTopNavFilter( + geotags = emptySet(), + relayList = proxyRelays.ifEmpty { outboxRelays }, + ) + } + + override fun flow() = combine(location, outboxRelays, proxyRelays, ::convert) + + override fun startValue(): LocationTopNavFilter = convert(location.value, outboxRelays.value, proxyRelays.value) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt new file mode 100644 index 0000000000..6dce8b3cbe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class LocationTopNavFilter( + val geotags: Set, + val relayList: Set, +) : IFeedTopNavFilter { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean { + if (geotags.isEmpty()) return false + + return if (noteEvent is CommentEvent) { + noteEvent.isTaggedGeoHashes(geotags) || + noteEvent.isTaggedScopes(geotagScopes) + } else { + noteEvent.isTaggedGeoHashes(geotags) + } + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + LocationTopNavPerRelayFilterSet(relayList.associateWith { LocationTopNavPerRelayFilter(geotags) }), + ) + + override fun startValue(cache: LocalCache): LocationTopNavPerRelayFilterSet = + LocationTopNavPerRelayFilterSet( + relayList.associateWith { LocationTopNavPerRelayFilter(geotags) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..219c107192 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId + +@Immutable +class LocationTopNavPerRelayFilter( + val geotags: Set, +) : IFeedTopNavPerRelayFilter { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..85564aac7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class LocationTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt new file mode 100644 index 0000000000..3aa72e1f88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class GlobalFeedFlow( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + val default = GlobalTopNavFilter(outboxRelays, proxyRelays) + + override fun flow() = MutableStateFlow(default) + + override fun startValue(): GlobalTopNavFilter = default + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt new file mode 100644 index 0000000000..5bf4028349 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlin.collections.associateWith +import kotlin.collections.isNotEmpty + +@Immutable +class GlobalTopNavFilter( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event) = true + + override fun toPerRelayFlow(cache: LocalCache): Flow = + combine(outboxRelays, proxyRelays) { outboxRelays, proxyRelays -> + if (proxyRelays.isNotEmpty()) { + GlobalTopNavPerRelayFilterSet(proxyRelays.associateWith { GlobalTopNavPerRelayFilter }) + } else { + GlobalTopNavPerRelayFilterSet(outboxRelays.associateWith { GlobalTopNavPerRelayFilter }) + } + } + + override fun startValue(cache: LocalCache): GlobalTopNavPerRelayFilterSet = + if (proxyRelays.value.isNotEmpty()) { + GlobalTopNavPerRelayFilterSet(proxyRelays.value.associateWith { GlobalTopNavPerRelayFilter }) + } else { + GlobalTopNavPerRelayFilterSet(outboxRelays.value.associateWith { GlobalTopNavPerRelayFilter }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..772a298dca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +object GlobalTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..2e30e5dc53 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class GlobalTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt new file mode 100644 index 0000000000..47235adc20 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class HashtagTopNavFilter( + val hashtags: Set, + val relayList: Set, +) : IFeedTopNavFilter { + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is CommentEvent) { + noteEvent.isTaggedHashes(hashtags) || noteEvent.isTaggedScopes(hashtagScopes) + } else { + noteEvent.isTaggedHashes(hashtags) + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + HashtagTopNavPerRelayFilterSet( + relayList.associateWith { HashtagTopNavPerRelayFilter(hashtags) }, + ), + ) + + override fun startValue(cache: LocalCache): HashtagTopNavPerRelayFilterSet = + HashtagTopNavPerRelayFilterSet( + relayList.associateWith { HashtagTopNavPerRelayFilter(hashtags) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..8c5a0406f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +@Immutable +class HashtagTopNavPerRelayFilter( + val hashtags: Set, +) : IFeedTopNavPerRelayFilter { + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..35c5481b0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class HashtagTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt new file mode 100644 index 0000000000..1bd864bb43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased + +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform + +class NoteFeedFlow( + val metadataFlow: StateFlow, + val signer: NostrSigner, + val outboxRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, + val caches: FeedDecryptionCaches, +) : IFeedFlowsType { + fun processByOutbox( + noteEvent: Event, + outboxRelays: Set, + ): IFeedTopNavFilter = + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) + } else { + AuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) + } + } + is MuteListEvent -> { + MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), blockedRelays) + } + is FollowListEvent -> { + AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays) + } + is CommunityListEvent -> { + AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) + } + is HashtagListEvent -> { + HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), outboxRelays) + } + is GeohashListEvent -> { + LocationTopNavFilter(caches.geohashCache.cachedGeohashes(noteEvent), outboxRelays) + } + is CommunityDefinitionEvent -> { + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ) + } + else -> AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + } + + suspend fun FlowCollector.processByOutbox( + noteEvent: Event, + outboxRelays: Set, + ) { + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + emit(MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) + } else { + emit(AuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) + } + } + is MuteListEvent -> { + emit(MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), blockedRelays)) + } + is FollowListEvent -> { + emit(AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays)) + } + is CommunityListEvent -> { + emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) + } + is HashtagListEvent -> { + emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), outboxRelays)) + } + is GeohashListEvent -> { + emit(LocationTopNavFilter(caches.geohashCache.geohashes(noteEvent), outboxRelays)) + } + is CommunityDefinitionEvent -> { + emit( + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ), + ) + } + else -> + emit( + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays), + ) + } + } + + fun processByProxy( + noteEvent: Event, + proxyRelays: Set, + ): IFeedTopNavFilter = + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) + } else { + AuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) + } + } + is MuteListEvent -> { + MutedAuthorsByProxyTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), proxyRelays) + } + is FollowListEvent -> { + AuthorsByProxyTopNavFilter(noteEvent.followIdSet(), proxyRelays) + } + is CommunityListEvent -> { + AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) + } + is HashtagListEvent -> { + HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), proxyRelays) + } + is GeohashListEvent -> { + LocationTopNavFilter(caches.geohashCache.cachedGeohashes(noteEvent), proxyRelays) + } + is CommunityDefinitionEvent -> { + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ) + } + else -> AuthorsByProxyTopNavFilter(emptySet(), proxyRelays) + } + + suspend fun FlowCollector.processByProxy( + noteEvent: Event, + proxyRelays: Set, + ) { + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + emit(MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) + } else { + emit(AuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) + } + } + is MuteListEvent -> { + emit(MutedAuthorsByProxyTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), proxyRelays)) + } + is FollowListEvent -> { + emit(AuthorsByProxyTopNavFilter(noteEvent.followIdSet(), proxyRelays)) + } + is CommunityListEvent -> { + emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) + } + is HashtagListEvent -> { + emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), proxyRelays)) + } + is GeohashListEvent -> { + emit(LocationTopNavFilter(caches.geohashCache.geohashes(noteEvent), proxyRelays)) + } + is CommunityDefinitionEvent -> { + emit( + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ), + ) + } + else -> + emit( + AuthorsByProxyTopNavFilter(emptySet(), proxyRelays), + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow() = + combineTransform(metadataFlow, outboxRelays, proxyRelays) { noteState, outboxRelays, proxyRelays -> + val noteEvent = noteState?.note?.event + if (noteEvent == null) { + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + } else { + if (proxyRelays.isEmpty()) { + processByOutbox(noteEvent, outboxRelays) + } else { + processByProxy(noteEvent, proxyRelays) + } + } + } + + override fun startValue(): IFeedTopNavFilter { + val noteEvent = metadataFlow.value?.note?.event + return if (noteEvent == null) { + return AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + } else { + if (proxyRelays.value.isEmpty()) { + processByOutbox(noteEvent, outboxRelays.value) + } else { + processByProxy(noteEvent, proxyRelays.value) + } + } + } + + override suspend fun startValue(collector: FlowCollector) { + val noteEvent = metadataFlow.value?.note?.event + if (noteEvent == null) { + collector.emit(AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays)) + } else { + if (proxyRelays.value.isEmpty()) { + collector.processByOutbox(noteEvent, outboxRelays.value) + } else { + collector.processByProxy(noteEvent, proxyRelays.value) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt new file mode 100644 index 0000000000..40debbe42d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class AllCommunitiesTopNavFilter( + val communities: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = noteEvent.isTaggedAddressableNotes(communities) + + fun convert(map: Map>) = + AllCommunitiesTopNavPerRelayFilterSet( + map.mapValues { AllCommunitiesTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val communitiesPerRelay = CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it } + + return combine(communitiesPerRelay, blockedRelays) { communitiesPerRelay, blockedRelays -> + convert(communitiesPerRelay.minus(blockedRelays)) + } + } + + override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet { + val communitiesPerRelay = CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it } + + return convert(communitiesPerRelay.minus(blockedRelays.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..dd532c3e99 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +class AllCommunitiesTopNavPerRelayFilter( + val communities: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..061c40e5a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AllCommunitiesTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt new file mode 100644 index 0000000000..63dc77fdbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class AuthorsByOutboxTopNavFilter( + val authors: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + fun convert(map: Map>) = + AuthorsTopNavPerRelayFilterSet( + map.mapValues { AuthorsTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authors, blocked -> + convert(authors.minus(blocked)) + } + } + + override fun startValue(cache: LocalCache): AuthorsTopNavPerRelayFilterSet { + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return convert(authorsPerRelay.minus(blockedRelays.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt new file mode 100644 index 0000000000..4489c55eec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class AuthorsByProxyTopNavFilter( + val authors: Set, + val proxyRelays: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + AuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { AuthorsTopNavPerRelayFilter(authors) }, + ), + ) + + override fun startValue(cache: LocalCache): AuthorsTopNavPerRelayFilterSet = + AuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { AuthorsTopNavPerRelayFilter(authors) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..abd72b1ec5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class AuthorsTopNavPerRelayFilter( + val authors: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..8809e74964 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AuthorsTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt new file mode 100644 index 0000000000..7b090f141e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +@Immutable +class SingleCommunityTopNavFilter( + val community: String, + val authors: Set?, + val relays: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || noteEvent.isTaggedAddressableNote(community) + } else if (noteEvent is CommentEvent) { + (authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community) + } else { + (authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community) + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + // relay field takes priority + if (relays.isNotEmpty()) { + return blockedRelays.map { blocked -> + SingleCommunityTopNavPerRelayFilterSet( + relays.minus(blocked).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + } + + if (authors != null) { + // go by authors + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authorsPerRelay, blocked -> + SingleCommunityTopNavPerRelayFilterSet( + authorsPerRelay.minus(blocked).mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) + } + } + + // go by hints + return blockedRelays.map { blocked -> + SingleCommunityTopNavPerRelayFilterSet( + cache.relayHints.hintsForAddress(community).minus(blocked).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + } + + override fun startValue(cache: LocalCache): SingleCommunityTopNavPerRelayFilterSet { + // relay field takes priority + if (relays.isNotEmpty()) { + return SingleCommunityTopNavPerRelayFilterSet( + relays.minus(blockedRelays.value).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + + if (authors != null) { + // go by authors + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return SingleCommunityTopNavPerRelayFilterSet( + authorsPerRelay.minus(blockedRelays.value).mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) + } + + // go by hints + return SingleCommunityTopNavPerRelayFilterSet( + cache.relayHints.hintsForAddress(community).minus(blockedRelays.value).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt similarity index 76% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt index 6e6201042a..596af8a4c5 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt @@ -18,14 +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.ammolite.relays +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community -import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter -class TypedFilter( - val types: Set, - val filter: IPerRelayFilter, -) { - // This only exists because some relays confuse empty lists with null lists - fun isValidFor(url: String) = filter.isValidFor(url) -} +@Immutable +class SingleCommunityTopNavPerRelayFilter( + val community: String, + val authors: Set?, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..c376579fa0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class SingleCommunityTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt new file mode 100644 index 0000000000..b2a5b68ea5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class MutedAuthorsByOutboxTopNavFilter( + val authors: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + fun convert(map: Map>) = + MutedAuthorsTopNavPerRelayFilterSet( + map.mapValues { MutedAuthorsTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authors, blocked -> + convert(authors.minus(blocked)) + } + } + + override fun startValue(cache: LocalCache): MutedAuthorsTopNavPerRelayFilterSet { + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return convert(authorsPerRelay.minus(blockedRelays.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt new file mode 100644 index 0000000000..36eea59a9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class MutedAuthorsByProxyTopNavFilter( + val authors: Set, + val proxyRelays: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + MutedAuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { MutedAuthorsTopNavPerRelayFilter(authors) }, + ), + ) + + override fun startValue(cache: LocalCache): MutedAuthorsTopNavPerRelayFilterSet = + MutedAuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { MutedAuthorsTopNavPerRelayFilter(authors) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..b56f83feba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class MutedAuthorsTopNavPerRelayFilter( + val authors: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..69d4c1029b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class MutedAuthorsTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt new file mode 100644 index 0000000000..075f449ca8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow + +class UnknownFeedFlow( + val feedName: String, +) : IFeedFlowsType { + override fun flow() = MutableStateFlow(UnknownTopNavFilter(feedName)) + + // empty feed + override fun startValue(): UnknownTopNavFilter = UnknownTopNavFilter(feedName) + + // empty feed + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt new file mode 100644 index 0000000000..c20e5793fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class UnknownTopNavFilter( + val feedName: String, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = false + + override fun match(noteEvent: Event) = false + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(UnknownTopNavPerRelayFilterSet) + + override fun startValue(cache: LocalCache) = UnknownTopNavPerRelayFilterSet +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..556f596fc9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet + +object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt new file mode 100644 index 0000000000..1041e69ccf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +class TorRelayEvaluation( + val torSettings: TorRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useTor(relay: NormalizedRelayUrl): Boolean = + if (torSettings.torType == TorType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isOnion()) { + torSettings.onionRelaysViaTor + } else if (relay in dmRelayList) { + torSettings.dmRelaysViaTor + } else if (relay in trustedRelayList) { + torSettings.trustedRelaysViaTor + } else { + torSettings.newRelaysViaTor + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt new file mode 100644 index 0000000000..e33477d052 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType + +data class TorRelaySettings( + val torType: TorType = TorType.OFF, + val onionRelaysViaTor: Boolean = true, + val dmRelaysViaTor: Boolean = false, + val trustedRelaysViaTor: Boolean = false, + val newRelaysViaTor: Boolean = false, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt new file mode 100644 index 0000000000..f1190941eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class TorRelayState( + val trustedRelayState: TrustedRelayListsState, + val dmRelayState: DmRelayListState, + val settings: AccountSettings, + val scope: CoroutineScope, +) { + val torSettings = + combine( + settings.torSettings.torType, + settings.torSettings.onionRelaysViaTor, + settings.torSettings.dmRelaysViaTor, + settings.torSettings.trustedRelaysViaTor, + settings.torSettings.newRelaysViaTor, + ) { + torType: TorType, + onionRelaysViaTor: Boolean, + dmRelaysViaTor: Boolean, + trustedRelaysViaTor: Boolean, + newRelaysViaTor: Boolean, + -> + TorRelaySettings( + torType = torType, + onionRelaysViaTor = onionRelaysViaTor, + dmRelaysViaTor = dmRelaysViaTor, + trustedRelaysViaTor = trustedRelaysViaTor, + newRelaysViaTor = newRelaysViaTor, + ) + }.onStart { + emit( + TorRelaySettings( + torType = settings.torSettings.torType.value, + onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value, + dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value, + trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value, + newRelaysViaTor = settings.torSettings.newRelaysViaTor.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + TorRelaySettings( + torType = settings.torSettings.torType.value, + onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value, + dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value, + trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value, + newRelaysViaTor = settings.torSettings.newRelaysViaTor.value, + ), + ) + + val flow = + combineTransform( + torSettings, + trustedRelayState.flow, + dmRelayState.flow, + ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + emit( + TorRelayEvaluation( + torSettings = torSettings, + trustedRelayList = trustedRelayList, + dmRelayList = dmRelayList, + ), + ) + }.onStart { + emit( + TorRelayEvaluation( + torSettings = torSettings.value, + trustedRelayList = trustedRelayState.flow.value, + dmRelayList = dmRelayState.flow.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + TorRelayEvaluation( + torSettings = torSettings.value, + trustedRelayList = trustedRelayState.flow.value, + dmRelayList = dmRelayState.flow.value, + ), + ) + + fun shouldUseTorForClean(relay: NormalizedRelayUrl) = flow.value.useTor(relay) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt deleted file mode 100644 index 08d8fdf5e6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import android.content.Context -import android.util.LruCache -import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.Base64 -import kotlin.coroutines.cancellation.CancellationException - -@Immutable -data class CashuToken( - val token: String, - val mint: String, - val totalAmount: Long, - val proofs: List, -) - -@Serializable -@Immutable -class Proof( - val amount: Int, - val id: String, - val secret: String, - val C: String, -) - -object CachedCashuProcessor { - val cashuCache = LruCache>>(20) - - fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() - - fun parse(token: String): GenericLoadable> { - if (cashuCache[token] !is GenericLoadable.Loaded) { - checkNotInMainThread() - val newCachuData = CashuProcessor().parse(token) - - cashuCache.put(token, newCachuData) - } - - return cashuCache[token] - } -} - -class CashuProcessor { - @Serializable - class V3Token( - val unit: String?, - val memo: String?, - val token: List?, - ) - - @Serializable - class V3T( - val mint: String, - val proofs: List, - ) - - fun parse(cashuToken: String): GenericLoadable> { - checkNotInMainThread() - - if (cashuToken.startsWith("cashuA")) { - return parseCashuA(cashuToken) - } - - if (cashuToken.startsWith("cashuB")) { - return parseCashuB(cashuToken) - } - - return GenericLoadable.Error("Could not parse this cashu token") - } - - fun parseCashuA(cashuToken: String): GenericLoadable> { - checkNotInMainThread() - - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) - - if (cashu.token == null) { - return GenericLoadable.Error("No token found") - } - - val converted = - cashu.token.map { token -> - val proofs = token.proofs - val mint = token.mint - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - - @Serializable - class V4Token( - // mint - val m: String, - // unit - val u: String, - // memo - val d: String? = null, - val t: Array?, - ) - - @Serializable - class V4T( - // identifier - @ByteString - val i: ByteArray, - val p: Array, - ) - - @Serializable - class V4Proof( - // amount - val a: Int, - // secret - val s: String, - // signature - @ByteString - val c: ByteArray, - // no idea what this is - val d: V4DleqProof? = null, - // witness - val w: String? = null, - ) - - @Serializable - class V4DleqProof( - @ByteString - val e: ByteArray, - @ByteString - val s: ByteArray, - @ByteString - val r: ByteArray, - ) - - @OptIn(ExperimentalSerializationApi::class) - fun parseCashuB(cashuToken: String): GenericLoadable> { - checkNotInMainThread() - - try { - val base64token = cashuToken.replace("cashuB", "") - - val parser = Cbor { ignoreUnknownKeys = true } - - val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) - - val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") - - val converted = - v4proofs.map { id -> - val proofs = - id.p.map { - Proof( - it.a, - id.i.toHexKey(), - it.s, - it.c.toHexKey(), - ) - } - val mint = v4Token.m - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - e.printStackTrace() - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - - suspend fun melt( - token: CashuToken, - lud16: String, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() - - runCatching { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - // Make invoice and leave room for fees - milliSats = token.totalAmount * 1000, - message = "Calculate Fees for Cashu", - okHttpClient = okHttpClient, - onSuccess = { baseInvoice -> - feeCalculator( - token.mint, - baseInvoice, - okHttpClient = okHttpClient, - onSuccess = { fees -> - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - // Make invoice and leave room for fees - milliSats = (token.totalAmount - fees) * 1000, - message = "Redeem Cashu", - okHttpClient = okHttpClient, - onSuccess = { invoice -> - meltInvoice(token, invoice, fees, okHttpClient, onSuccess, onError, context) - }, - onProgress = {}, - onError = onError, - context = context, - ) - }, - onError = onError, - context, - ) - }, - onProgress = {}, - onError = onError, - context = context, - ) - } - } - - fun feeCalculator( - mintAddress: String, - invoice: String, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (Int) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() - - try { - val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint - val client = okHttpClient(url) - - val factory = JsonNodeFactory.instance - - val jsonObject = factory.objectNode() - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() - - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val feeCost = tree?.get("fee")?.asInt() - - if (feeCost != null) { - onSuccess( - feeCost, - ) - } else { - val msg = - tree - ?.get("detail") - ?.asText() - ?.split('.') - ?.getOrNull(0) - ?.ifBlank { null } - onError( - stringRes(context, R.string.cashu_failed_redemption), - if (msg != null) { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - onError( - stringRes(context, R.string.cashu_successful_redemption), - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) - } - } - - private fun meltInvoice( - token: CashuToken, - invoice: String, - fees: Int, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - try { - val url = token.mint + "/melt" // Melt cashu tokens at Mint - val client = okHttpClient(url) - - val factory = JsonNodeFactory.instance - - val jsonObject = factory.objectNode() - - jsonObject.replace( - "proofs", - factory.arrayNode(token.proofs.size).apply { - token.proofs.forEach { - addObject().apply { - put("amount", it.amount) - put("id", it.id) - put("secret", it.secret) - put("C", it.C) - } - } - }, - ) - - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() - - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val successful = tree?.get("paid")?.asText() == "true" - - if (successful) { - onSuccess( - stringRes(context, R.string.cashu_successful_redemption), - stringRes( - context, - R.string.cashu_successful_redemption_explainer, - token.totalAmount.toString(), - fees.toString(), - ), - ) - } else { - val msg = - tree - ?.get("detail") - ?.asText() - ?.split('.') - ?.getOrNull(0) - ?.ifBlank { null } - onError( - stringRes(context, R.string.cashu_failed_redemption), - if (msg != null) { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - onError( - stringRes(context, R.string.cashu_successful_redemption), - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index c3b0497a8b..35fb7b90dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -20,18 +20,18 @@ */ package com.vitorpamplona.amethyst.service -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class Nip05NostrAddressVerifier { suspend fun fetchNip05Json( nip05: String, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) = withContext(Dispatchers.IO) { @@ -45,30 +45,22 @@ class Nip05NostrAddressVerifier { } try { - val request = - Request - .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() - // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. - okttpClient(url) - .newBuilder() - .followRedirects(false) - .build() - .newCall(request) - .execute() - .use { - checkNotInMainThread() + val request = Request.Builder().url(url).build() - if (it.isSuccessful) { - onSuccess(it.body.string()) + // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. + val client = okHttpClient(url).newBuilder().followRedirects(false).build() + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + onSuccess(response.body.string()) } else { onError( - "Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct", + "Could not resolve $nip05. Error: ${response.code}. Check if the server is up and if the address $nip05 is correct", ) } } + } } catch (e: Exception) { if (e is CancellationException) throw e onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}") @@ -77,7 +69,7 @@ class Nip05NostrAddressVerifier { suspend fun verifyNip05( nip05: String, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) { @@ -86,7 +78,7 @@ class Nip05NostrAddressVerifier { fetchNip05Json( nip05, - okttpClient, + okHttpClient, onSuccess = { checkNotInMainThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index 9b1d652d0e..d58d29dd35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -22,94 +22,124 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException -import okhttp3.Call -import okhttp3.Callback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import java.io.IOException +import okhttp3.coroutines.executeAsync object Nip11CachedRetriever { - open class RetrieveResult( - val time: Long, - ) - - class RetrieveResultError( - val error: Nip11Retriever.ErrorCode, - val msg: String? = null, - ) : RetrieveResult(TimeUtils.now()) - - class RetrieveResultSuccess( + sealed class RetrieveResult( val data: Nip11RelayInformation, - ) : RetrieveResult(TimeUtils.now()) + val time: Long, + ) { + class Error( + data: Nip11RelayInformation, + val error: Nip11Retriever.ErrorCode, + val msg: String? = null, + ) : RetrieveResult(data, TimeUtils.now()) - class RetrieveResultLoading : RetrieveResult(TimeUtils.now()) + class Success( + data: Nip11RelayInformation, + ) : RetrieveResult(data, TimeUtils.now()) - private val relayInformationDocumentCache = LruCache(100) + class Loading( + data: Nip11RelayInformation, + ) : RetrieveResult(data, TimeUtils.now()) + + class Empty( + data: Nip11RelayInformation, + ) : RetrieveResult(data, TimeUtils.now()) + } + + private val relayInformationEmptyCache = LruCache(1000) + private val relayInformationDocumentCache = LruCache(1000) private val retriever = Nip11Retriever() - fun getFromCache(dirtyUrl: String): Nip11RelayInformation? { - val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null - if (result is RetrieveResultSuccess) return result.data - return null + fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation { + relayInformationEmptyCache.get(relay)?.let { return it } + + val info = + Nip11RelayInformation( + name = relay.displayUrl(), + icon = relay.toHttp() + "favicon.ico", + ) + + relayInformationEmptyCache.put(relay, info) + + return info + } + + fun getFromCache(relay: NormalizedRelayUrl): Nip11RelayInformation { + val result = relayInformationDocumentCache.get(relay) + + return when (result) { + is RetrieveResult.Success -> return result.data + is RetrieveResult.Error -> return result.data + is RetrieveResult.Empty -> return result.data + is RetrieveResult.Loading -> return result.data + else -> { + val empty = getEmpty(relay) + relayInformationDocumentCache.put(relay, RetrieveResult.Empty(empty)) + empty + } + } } suspend fun loadRelayInfo( - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { - checkNotInMainThread() - val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl) - val doc = relayInformationDocumentCache.get(url) - + val doc = relayInformationDocumentCache.get(relay) if (doc != null) { - if (doc is RetrieveResultSuccess) { + if (doc is RetrieveResult.Success) { onInfo(doc.data) - } else if (doc is RetrieveResultLoading) { + } else if (doc is RetrieveResult.Loading) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) { // just wait. } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } - } else if (doc is RetrieveResultError) { + } else if (doc is RetrieveResult.Error) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) { - onError(dirtyUrl, doc.error, null) + onError(relay, doc.error, null) } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } + } else { + // Empty + retrieve(relay, okHttpClient, onInfo, onError) } } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } } private suspend fun retrieve( - url: String, - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { - relayInformationDocumentCache.put(url, RetrieveResultLoading()) + relayInformationDocumentCache.put(relay, RetrieveResult.Loading(getEmpty(relay))) retriever.loadRelayInfo( - url = url, - dirtyUrl = dirtyUrl, + relay = relay, okHttpClient = okHttpClient, onInfo = { - checkNotInMainThread() - relayInformationDocumentCache.put(url, RetrieveResultSuccess(it)) + relayInformationDocumentCache.put(relay, RetrieveResult.Success(it)) onInfo(it) }, - onError = { dirtyUrl, code, errorMsg -> - checkNotInMainThread() - relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg)) - onError(url, code, errorMsg) + onError = { relay, code, errorMsg -> + relayInformationDocumentCache.put(relay, RetrieveResult.Error(getEmpty(relay), code, errorMsg)) + onError(relay, code, errorMsg) }, ) } @@ -124,13 +154,12 @@ class Nip11Retriever { } suspend fun loadRelayInfo( - url: String, - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, ) { - checkNotInMainThread() + val url = relay.toHttp() try { val request: Request = Request @@ -139,48 +168,32 @@ class Nip11Retriever { .url(url) .build() - okHttpClient(url) - .newCall(request) - .enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - onInfo(Nip11RelayInformation.fromJson(body)) - } else { - onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e( - "RelayInfoFail", - "Resulting Message from Relay $dirtyUrl in not parseable: $body", - e, - ) - onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) - } - } - } + val client = okHttpClient(url) - override fun onFailure( - call: Call, - e: IOException, - ) { - Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + try { + if (response.isSuccessful) { + onInfo(Nip11RelayInformation.fromJson(body)) + } else { + onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString()) } - }, - ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e( + "RelayInfoFail", + "Resulting Message from Relay ${relay.url} in not parseable: $body", + e, + ) + onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + } + } + } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 5136c0c2f9..04f21d2fdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.utils.RandomInstance import okhttp3.EventListener import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request +import okhttp3.coroutines.executeAsync import okio.ByteString.Companion.toByteString import kotlin.coroutines.cancellation.CancellationException @@ -49,12 +49,10 @@ object OnlineChecker { return false } - fun isOnline( + suspend fun isOnline( url: String?, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, ): Boolean { - checkNotInMainThread() - if (url.isNullOrBlank()) return false if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { return checkOnlineCache.get(url).online @@ -66,7 +64,6 @@ object OnlineChecker { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url.replace("wss+livekit://", "wss://")) .header("Upgrade", "websocket") .header("Connection", "Upgrade") @@ -76,29 +73,23 @@ object OnlineChecker { .build() val client = - okttpClient(url) + okHttpClient(url) .newBuilder() .eventListener(EventListener.NONE) .protocols(listOf(Protocol.HTTP_1_1)) .build() - client.newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } + client.newCall(request).executeAsync().use { it.isSuccessful } } else { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() + val client = okHttpClient(url) - okttpClient(url).newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } + client.newCall(request).executeAsync().use { it.isSuccessful } } checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt index 27ec2f6d3d..e21d1759b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.service import android.annotation.SuppressLint import android.content.Context -import android.content.Intent -import androidx.core.net.toUri object PackageUtils { @SuppressLint("QueryPermissionsNeeded") @@ -36,14 +34,4 @@ object PackageUtils { } != null fun isOrbotInstalled(context: Context): Boolean = isPackageInstalled(context, "org.torproject.android") - - fun isExternalSignerInstalled(context: Context): Boolean { - val intent = - Intent().apply { - action = Intent.ACTION_VIEW - data = "nostrsigner:".toUri() - } - val infos = context.packageManager.queryIntentActivities(intent, 0) - return infos.size > 0 - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 19148b2ac2..42adc194f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -23,24 +23,25 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.collectSuccessfulOperations 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.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -51,12 +52,25 @@ class ZapPaymentHandler( ) { @Immutable data class Payable( - val info: BaseZapSplitSetup, - val user: User?, + val info: MyZapSplitSetup, val amountMilliSats: Long, val invoice: String, ) + data class UnverifiedZapSplitSetup( + val lnAddress: String?, + val weight: Double = 1.0, + val relay: NormalizedRelayUrl? = null, + val user: User? = null, + ) + + data class MyZapSplitSetup( + val lnAddress: String, + val weight: Double = 1.0, + val relay: NormalizedRelayUrl? = null, + val user: User? = null, + ) + suspend fun zap( note: Note, amountMilliSats: Long, @@ -73,87 +87,118 @@ class ZapPaymentHandler( val noteEvent = note.event val zapSplitSetup = noteEvent?.zapSplitSetup() - val zapsToSend = + val unverifiedZapsToSend = if (!zapSplitSetup.isNullOrEmpty()) { - zapSplitSetup + zapSplitSetup.map { setup -> + when (setup) { + is ZapSplitSetupLnAddress -> { + UnverifiedZapSplitSetup( + lnAddress = setup.lnAddress, + weight = setup.weight, + ) + } + is ZapSplitSetup -> { + val user = LocalCache.checkGetOrCreateUser(setup.pubKeyHex) + UnverifiedZapSplitSetup( + lnAddress = user?.info?.lnAddress(), + weight = setup.weight, + relay = setup.relay, + user = user, + ) + } + } + } } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { - noteEvent.hosts().map { ZapSplitSetup(it.pubKey, it.relayHint, weight = 1.0) } + noteEvent.hosts().map { + val user = LocalCache.checkGetOrCreateUser(it.pubKey) + val lnAddress = user?.info?.lnAddress() + UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user) + } } else if (noteEvent is AppDefinitionEvent) { val appLud16 = noteEvent.appMetaData()?.lnAddress() if (appLud16 != null) { - listOf(ZapSplitSetupLnAddress(appLud16, weight = 1.0)) + listOf(UnverifiedZapSplitSetup(appLud16)) } else { val lud16 = note.author?.info?.lnAddress() - - if (lud16.isNullOrBlank()) { - if (showErrorIfNoLnAddress) { - onError( - stringRes(context, R.string.missing_lud16), - stringRes( - context, - R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, - ), - note.author, - ) - } - return@withContext - } - - listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0)) + listOf(UnverifiedZapSplitSetup(lud16)) } } else { - val lud16 = note.author?.info?.lnAddress() + listOf(UnverifiedZapSplitSetup(note.author?.info?.lnAddress())) + } - if (lud16.isNullOrBlank()) { - if (showErrorIfNoLnAddress) { - onError( - stringRes(context, R.string.missing_lud16), - stringRes( - context, - R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, - ), - note.author, + if (showErrorIfNoLnAddress) { + val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() } + errors.forEach { + val message = + if (it.user != null) { + stringRes( + context, + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + it.user.toBestDisplayName(), ) + } else { + stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats) } - return@withContext - } - listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0)) + onError( + stringRes(context, R.string.missing_lud16), + message, + it.user, + ) + } + } + + val zapsToSend = + unverifiedZapsToSend.mapNotNull { + if (it.lnAddress != null) { + MyZapSplitSetup( + it.lnAddress, + it.weight, + it.relay, + it.user, + ) + } else { + null + } } onProgress(0.02f) - signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs -> - if (splitZapRequestPairs.isEmpty()) { - onProgress(0.00f) - return@signAllZapRequests - } else { - onProgress(0.05f) - } - assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, okHttpClient, onError, onProgress = { - onProgress(it * 0.7f + 0.05f) // keeps within range. - }, context) { payables -> - if (payables.isEmpty()) { - onProgress(0.00f) - return@assembleAllInvoices - } else { - onProgress(0.75f) - } + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend) - if (account.hasWalletConnectSetup()) { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) { - // onProgress(1f) - } - } else { - onPayViaIntent( - payables.toImmutableList(), - ) + if (splitZapRequests.isEmpty()) { + onProgress(0.00f) + return@withContext + } else { + onProgress(0.05f) + } - onProgress(0f) - } - } + val payables = + assembleAllInvoices( + requests = splitZapRequests, + totalAmountMilliSats = amountMilliSats, + message = message, + okHttpClient = okHttpClient, + onError = onError, + onProgress = { onProgress(it * 0.7f + 0.05f) }, + context = context, + ) + + if (payables.isEmpty()) { + onProgress(0.00f) + return@withContext + } else { + onProgress(0.75f) + } + + if (account.nip47SignerState.hasWalletConnectSetup()) { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + // onProgress(1f) + } else { + onPayViaIntent(payables.toImmutableList()) + onProgress(0f) } } @@ -168,9 +213,8 @@ class ZapPaymentHandler( } class ZapRequestReady( - val inputSetup: BaseZapSplitSetup, - val zapRequestJson: String?, - val user: User? = null, + val inputSetup: MyZapSplitSetup, + val zapRequest: LnZapRequestEvent?, ) suspend fun signAllZapRequests( @@ -178,87 +222,74 @@ class ZapPaymentHandler( pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, - zapsToSend: List, - onAllDone: suspend (List) -> Unit, - ) { - val authorRelayList = - note.author - ?.pubkeyHex - ?.let { - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(it), - )?.event as? AdvertisedRelayListEvent? - )?.readRelays() - }?.toSet() + zapsToSend: List, + ): List = + mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> + // makes sure the author receives the zap event + val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet() - collectSuccessfulOperations( - items = zapsToSend, - runRequestFor = { next: BaseZapSplitSetup, onReady -> - if (next is ZapSplitSetupLnAddress) { - prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson -> - if (zapRequestJson != null) { - onReady(ZapRequestReady(next, zapRequestJson)) - } - } - } else if (next is ZapSplitSetup) { - val user = LocalCache.getUserIfExists(next.pubKeyHex) - val userRelayList = - ( - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(next.pubKeyHex), - )?.event as? AdvertisedRelayListEvent? - )?.readRelays()?.toSet() ?: emptySet() - ) + (authorRelayList ?: emptySet()) + // makes sure the zap split user receives the zap event + val userRelayList = next.user?.inboxRelays()?.toSet() ?: emptySet() - prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson -> - onReady(ZapRequestReady(next, zapRequestJson, user)) - } + val noteEvent = note.event + + val zapRequest = + if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { + account.createZapRequestFor(noteEvent, pollOption, message, zapType, next.user, userRelayList + authorRelayList) + } else { + null } - }, - onReady = onAllDone, - ) - } + + ZapRequestReady(next, zapRequest) + } suspend fun assembleAllInvoices( requests: List, totalAmountMilliSats: Long, message: String, - showErrorIfNoLnAddress: Boolean, okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (List) -> Unit, - ) { + ): List { var progressAllPayments = 0.00f val totalWeight = requests.sumOf { it.inputSetup.weight } - collectSuccessfulOperations( - items = requests, - runRequestFor = { splitZapRequestPair: ZapRequestReady, onReady -> + return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady -> + try { assembleInvoice( + lud16 = splitZapRequestPair.inputSetup.lnAddress, splitSetup = splitZapRequestPair.inputSetup, - nostrZapRequest = splitZapRequestPair.zapRequestJson, - toUser = splitZapRequestPair.user, + nostrZapRequest = splitZapRequestPair.zapRequest, zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), message = message, - showErrorIfNoLnAddress = showErrorIfNoLnAddress, okHttpClient = okHttpClient, - onError = onError, onProgressStep = { percentStepForThisPayment -> progressAllPayments += percentStepForThisPayment / requests.size onProgress(progressAllPayments) }, context = context, - onReady = onReady, ) - }, - onReady = onAllDone, - ) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg, splitZapRequestPair.inputSetup.user) + null + } catch (e: Exception) { + if (e is CancellationException) throw e + onError( + stringRes( + context, + R.string.error_unable_to_fetch_invoice, + ), + stringRes( + context, + R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error, + e.message, + ), + null, + ) + null + } + } } class Paid( @@ -272,21 +303,15 @@ class ZapPaymentHandler( onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (List) -> Unit, - ) { + ): List { var progressAllPayments = 0.00f - collectSuccessfulOperations( + return mapNotNullAsync( items = payables, - runRequestFor = { payable: Payable, onReady -> + runRequestFor = { payable: Payable -> account.sendZapPaymentRequestFor( bolt11 = payable.invoice, zappedNote = note, - onSent = { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) - onReady(Paid(payable, true)) - }, onResponse = { response -> if (response is PayInvoiceErrorResponse) { progressAllPayments += 0.5f / payables.size @@ -299,7 +324,7 @@ class ZapPaymentHandler( response.error?.message ?: response.error?.code?.toString() ?: "Error parsing error message", ), - payable.user, + payable.info.user, ) } else { progressAllPayments += 0.5f / payables.size @@ -307,95 +332,48 @@ class ZapPaymentHandler( } }, ) + + progressAllPayments += 0.5f / payables.size + onProgress(progressAllPayments) + + Paid(payable, true) }, - onReady = onAllDone, ) } - private fun assembleInvoice( - splitSetup: BaseZapSplitSetup, - nostrZapRequest: String?, - toUser: User?, + private suspend fun assembleInvoice( + lud16: String, + splitSetup: MyZapSplitSetup, + nostrZapRequest: LnZapRequestEvent?, zapValue: Long, message: String, - showErrorIfNoLnAddress: Boolean = true, okHttpClient: (String) -> OkHttpClient, - onError: (String, String, User?) -> Unit, onProgressStep: (percent: Float) -> Unit, context: Context, - onReady: (Payable) -> Unit, - ) { + ): Payable { var progressThisPayment = 0.00f - val lud16 = - if (splitSetup is ZapSplitSetupLnAddress) { - splitSetup.lnAddress - } else { - toUser?.info?.lnAddress() - } + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + milliSats = zapValue, + message = message, + nostrRequest = nostrZapRequest, + okHttpClient = okHttpClient, + onProgress = { + val step = it - progressThisPayment + progressThisPayment = it + onProgressStep(step) + }, + context = context, + ) - if (lud16 != null) { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - milliSats = zapValue, - message = message, - nostrRequest = nostrZapRequest, - okHttpClient = okHttpClient, - onError = { title, msg -> - onError(title, msg, toUser) - }, - onProgress = { - val step = it - progressThisPayment - progressThisPayment = it - onProgressStep(step) - }, - context = context, - onSuccess = { - onProgressStep(1 - progressThisPayment) - onReady( - Payable( - info = splitSetup, - user = toUser, - amountMilliSats = zapValue, - invoice = it, - ), - ) - }, - ) - } else { - if (showErrorIfNoLnAddress) { - onError( - stringRes( - context, - R.string.missing_lud16, - ), - stringRes( - context, - R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - toUser?.toBestDisplayName() ?: splitSetup.mainId(), - ), - null, - ) - } - } - } + onProgressStep(1 - progressThisPayment) - private fun prepareZapRequestIfNeeded( - note: Note, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null, - additionalRelays: Set? = null, - onReady: (String?) -> Unit, - ) { - if (zapType != LnZapEvent.ZapType.NONZAP) { - account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> - onReady(zapRequest.toJson()) - } - } else { - onReady(null) - } + return Payable( + info = splitSetup, + amountMilliSats = zapValue, + invoice = invoice, + ) } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt similarity index 62% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt index e57f457805..c5a48dcc7c 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt @@ -18,29 +18,23 @@ * 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.ammolite.relays +package com.vitorpamplona.amethyst.service.cashu import android.util.LruCache -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import kotlinx.collections.immutable.ImmutableList -object RelayBriefInfoCache { - val cache = LruCache(50) +object CachedCashuParser { + val cashuCache = LruCache>>(20) - @Immutable - class RelayBriefInfo( - val url: String, - ) { - val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern() - val favIcon: String = "https://$displayUrl/favicon.ico".intern() - } + fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() - fun get(url: String): RelayBriefInfo { - val info = cache[url] - if (info != null) return info + fun parse(token: String): GenericLoadable> { + if (cashuCache[token] !is GenericLoadable.Loaded) { + val newCachuData = CashuParser().parse(token) + cashuCache.put(token, newCachuData) + } - val newInfo = RelayBriefInfo(url) - cache.put(url, newInfo) - return newInfo + return cashuCache[token] } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt new file mode 100644 index 0000000000..49101b4b4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu + +import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser +import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import kotlinx.collections.immutable.ImmutableList + +class CashuParser { + fun parse(cashuToken: String): GenericLoadable> { + checkNotInMainThread() + + if (cashuToken.startsWith("cashuA")) { + return V3Parser.parseCashuA(cashuToken) + } + + if (cashuToken.startsWith("cashuB")) { + return V4Parser.parseCashuB(cashuToken) + } + + return GenericLoadable.Error("Could not parse this cashu token") + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt similarity index 77% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt index 7d5868018e..6c0eeb6d69 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt @@ -18,23 +18,24 @@ * 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.ammolite.relays +package com.vitorpamplona.amethyst.service.cashu import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable @Immutable -data class RelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, +data class CashuToken( + val token: String, + val mint: String, + val totalAmount: Long, + val proofs: List, ) +@Serializable @Immutable -data class RelaySetupInfoToConnect( - val url: String, - val forceProxy: Boolean, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, +class Proof( + val amount: Int, + val id: String, + val secret: String, + val C: String, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt new file mode 100644 index 0000000000..c08ec63089 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.melt + +import android.content.Context +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync +import kotlin.coroutines.cancellation.CancellationException + +class MeltProcessor { + suspend fun melt( + token: CashuToken, + lud16: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): MeltResult { + val baseInvoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + // Make invoice and leave room for fees + milliSats = token.totalAmount * 1000, + message = "Calculate Fees for Cashu", + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + + val fees = + feeCalculator( + mintAddress = token.mint, + invoice = baseInvoice, + okHttpClient = okHttpClient, + context = context, + ) + + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + // Make invoice and leave room for fees + milliSats = (token.totalAmount - fees) * 1000, + message = "Redeem Cashu", + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + + meltInvoice(token, invoice, okHttpClient, context) + + return MeltResult( + token = token, + invoice = invoice, + fees = fees, + ) + } + + suspend fun melt( + token: CashuToken, + lud16: String, + okHttpClient: (String) -> OkHttpClient, + onSuccess: (String, String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + } + + suspend fun feeCalculator( + mintAddress: String, + invoice: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): Int = + try { + val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint + val client = okHttpClient(url) + + val factory = JsonNodeFactory.instance + + val jsonObject = factory.objectNode() + jsonObject.put("pr", invoice) + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = + Request + .Builder() + .url(url) + .post(requestBody) + .build() + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + val tree = jacksonObjectMapper().readTree(body) + + val feeCost = tree?.get("fee")?.asInt() + + if (feeCost == null) { + val msg = + tree + ?.get("detail") + ?.asText() + ?.split('.') + ?.getOrNull(0) + ?.ifBlank { null } + + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + if (msg != null) { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) + } + + feeCost + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) + } + + private suspend fun meltInvoice( + token: CashuToken, + invoice: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ) { + try { + val url = token.mint + "/melt" // Melt cashu tokens at Mint + val client = okHttpClient(url) + + val factory = JsonNodeFactory.instance + + val jsonObject = factory.objectNode() + + jsonObject.replace( + "proofs", + factory.arrayNode(token.proofs.size).apply { + token.proofs.forEach { + addObject().apply { + put("amount", it.amount) + put("id", it.id) + put("secret", it.secret) + put("C", it.C) + } + } + }, + ) + + jsonObject.put("pr", invoice) + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = + Request + .Builder() + .url(url) + .post(requestBody) + .build() + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + val tree = jacksonObjectMapper().readTree(body) + + val successful = tree?.get("paid")?.asText() == "true" + + if (!successful) { + val msg = + tree + ?.get("detail") + ?.asText() + ?.split('.') + ?.getOrNull(0) + ?.ifBlank { null } + + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + if (msg != null) { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_successful_redemption), + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt new file mode 100644 index 0000000000..11ad4711ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.melt + +import com.vitorpamplona.amethyst.service.cashu.CashuToken + +class MeltResult( + val token: CashuToken, + val invoice: String, + val fees: Int, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt new file mode 100644 index 0000000000..22aba4a818 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.v3 + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException + +class V3Parser { + companion object { + fun parseCashuA(cashuToken: String): GenericLoadable> { + try { + val base64token = cashuToken.replace("cashuA", "") + val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) + + if (cashu.token == null) { + return GenericLoadable.Error("No token found") + } + + val converted = + cashu.token.map { token -> + val proofs = token.proofs + val mint = token.mint + + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.amount + } + + CashuToken(cashuToken, mint, totalAmount, proofs) + } + + return GenericLoadable.Loaded(converted.toImmutableList()) + } catch (e: Exception) { + if (e is CancellationException) throw e + return GenericLoadable.Error("Could not parse this cashu token") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt new file mode 100644 index 0000000000..be244b2eda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.v3 + +import com.vitorpamplona.amethyst.service.cashu.Proof +import kotlinx.serialization.Serializable + +@Serializable +class V3Token( + val unit: String?, + val memo: String?, + val token: List?, +) + +@Serializable +class V3T( + val mint: String, + val proofs: List, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt index f03cafbb20..071b4c7d7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt @@ -18,31 +18,51 @@ * 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.relays +package com.vitorpamplona.amethyst.service.cashu.v4 -import android.content.Context -import android.util.Log -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString -class RelayService( - val context: Context, -) { - val status = - callbackFlow { - Log.d("RelayService", "Starting Relay Services") - trySend(RelayServiceStatus.Connecting) +@Serializable +class V4Token( + // mint + val m: String, + // unit + val u: String, + // memo + val d: String? = null, + val t: Array?, +) - // ServiceManager +@Serializable +class V4T( + // identifier + @ByteString + val i: ByteArray, + val p: Array, +) - awaitClose { - Log.d("RelayService", "Stopping Relay Services") - launch { - // ServiceManager.pauseAndLogOff() - } - trySend(RelayServiceStatus.Off) - } - }.distinctUntilChanged() -} +@Serializable +class V4Proof( + // amount + val a: Int, + // secret + val s: String, + // signature + @ByteString + val c: ByteArray, + // no idea what this is + val d: V4DleqProof? = null, + // witness + val w: String? = null, +) + +@Serializable +class V4DleqProof( + @ByteString + val e: ByteArray, + @ByteString + val s: ByteArray, + @ByteString + val r: ByteArray, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt new file mode 100644 index 0000000000..d26f54060c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.v4 + +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.service.cashu.Proof +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException + +class V4Parser { + companion object { + @OptIn(ExperimentalSerializationApi::class) + fun parseCashuB(cashuToken: String): GenericLoadable> { + try { + val base64token = cashuToken.replace("cashuB", "") + val parser = Cbor { ignoreUnknownKeys = true } + val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) + val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") + + val converted = + v4proofs.map { id -> + val proofs = + id.p.map { + Proof( + it.a, + id.i.toHexKey(), + it.s, + it.c.toHexKey(), + ) + } + val mint = v4Token.m + + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.amount + } + + CashuToken(cashuToken, mint, totalAmount, proofs) + } + + return GenericLoadable.Loaded(converted.toImmutableList()) + } catch (e: Exception) { + if (e is CancellationException) throw e + return GenericLoadable.Error("Could not parse this cashu token") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 1006244a5d..6d31d5578e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -36,6 +36,7 @@ class MemoryTrimmingService( account: Account? = null, otherAccounts: List, ) { + cache.cleanMemory() cache.cleanObservers() account?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index c21c791342..42cc045fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -23,142 +23,137 @@ package com.vitorpamplona.amethyst.service.lnurl import android.content.Context import android.util.Log import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.coroutines.executeAsync import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder import kotlin.coroutines.cancellation.CancellationException class LightningAddressResolver { - fun assembleUrl(lnaddress: String): String? { - val parts = lnaddress.split("@") + fun assembleUrl(lnAddress: String): String? { + val parts = lnAddress.split("@") if (parts.size == 2) { return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" } - if (lnaddress.lowercase().startsWith("lnurl")) { - return Lud06().toLnUrlp(lnaddress) + if (lnAddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnAddress) } return null } - private fun fetchLightningAddressJson( - lnaddress: String, - okttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() + class LightningAddressError( + val title: String, + val msg: String, + ) : Exception(msg) - val url = assembleUrl(lnaddress) + private suspend fun fetchLightningAddressJson( + lnAddress: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): String { + val url = assembleUrl(lnAddress) if (url == null) { - onError( + throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, - lnaddress, - ), + stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress), ) - return } - val client = okttpClient(url) + val client = okHttpClient(url) - try { + return try { val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, - url, - lnaddress, - errorMessage(it, context), - ), - ) + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.string() + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, + url, + lnAddress, + errorMessage(response, context), + ), + ) + } } } } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() - onError( + throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes( context, R.string .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception, url, - lnaddress, + lnAddress, e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message, ), ) } } - fun fetchLightningInvoice( + suspend fun fetchLightningInvoice( lnCallback: String, milliSats: Long, message: String, - nostrRequest: String? = null, - okttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, + nostrRequest: LnZapRequestEvent? = null, + okHttpClient: (String) -> OkHttpClient, context: Context, - ) { - checkNotInMainThread() - + ): String { val encodedMessage = URLEncoder.encode(message, "utf-8") val urlBinder = if (lnCallback.contains("?")) "&" else "?" var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" if (nostrRequest != null) { - val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") + val encodedNostrRequest = URLEncoder.encode(nostrRequest.toJson(), "utf-8") url += "&nostr=$encodedNostrRequest" } - val client = okttpClient(url) + val client = okHttpClient(url) val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)), - ) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.string() + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)), + ) + } } } } @@ -178,7 +173,7 @@ class LightningAddressResolver { val statusNode = tree.get("status") if (tree.get("error").isBoolean && messageNode != null) { - if (errorNode.asBoolean() == true) { + if (errorNode.asBoolean()) { return messageNode.asText() } } @@ -203,146 +198,136 @@ class LightningAddressResolver { ?: response.code.toString() } - fun lnAddressInvoice( - lnaddress: String, + suspend fun lnAddressInvoice( + lnAddress: String, milliSats: Long, message: String, - nostrRequest: String? = null, + nostrRequest: LnZapRequestEvent? = null, okHttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - ) { + ): String { val mapper = jacksonObjectMapper() - fetchLightningAddressJson( - lnaddress, - okHttpClient, - onSuccess = { lnAddressJson -> - onProgress(0.4f) + val lnAddressJson = + fetchLightningAddressJson( + lnAddress, + okHttpClient, + context, + ) - val lnurlp = - try { - mapper.readTree(lnAddressJson) - } catch (t: Throwable) { - if (t is CancellationException) throw t - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, - lnaddress, - ), - ) - null - } + onProgress(0.4f) - val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null } - - if (callback == null) { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, - lnaddress, - ), - ) - } - - val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false - - callback?.let { cb -> - fetchLightningInvoice( - cb, - milliSats, - message, - if (allowsNostr) nostrRequest else null, - okHttpClient, - onSuccess = { - onProgress(0.6f) - - val lnInvoice = - try { - mapper.readTree(it) - } catch (t: Throwable) { - if (t is CancellationException) throw t - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, - lnaddress, - ), - ) - null - } - - lnInvoice - ?.get("pr") - ?.asText() - ?.ifBlank { null } - ?.let { pr -> - // Forces LN Invoice amount to be the requested amount. - val expectedAmountInSats = - BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() - val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) - if (invoiceAmount.toLong() == expectedAmountInSats) { - onProgress(0.7f) - onSuccess(pr) - } else { - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.incorrect_invoice_amount_sats_from_it_should_have_been, - invoiceAmount.toLong().toString(), - lnaddress, - expectedAmountInSats.toString(), - ), - ) - } - } - ?: lnInvoice - ?.get("reason") - ?.asText() - ?.ifBlank { null } - ?.let { reason -> - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user, - lnaddress, - reason, - ), - ) - } - ?: run { - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, - lnaddress, - ), - ) - } - }, - onError = onError, + val lnurlp = + try { + mapper.readTree(lnAddressJson) + } catch (t: Throwable) { + if (t is CancellationException) throw t + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( context, - ) - } - }, - onError = onError, - context, - ) + R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, + lnAddress, + ), + ) + } + + val callbackUrl = lnurlp?.get("callback")?.asText()?.ifBlank { null } + + if (callbackUrl == null) { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, + lnAddress, + ), + ) + } + + val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + + val invoice = + fetchLightningInvoice( + lnCallback = callbackUrl, + milliSats = milliSats, + message = message, + nostrRequest = if (allowsNostr) nostrRequest else null, + okHttpClient = okHttpClient, + context = context, + ) + + onProgress(0.6f) + + val lnInvoice = + try { + mapper.readTree(invoice) + } catch (t: Throwable) { + if (t is CancellationException) throw t + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, + lnAddress, + ), + ) + } + + val pr = lnInvoice?.get("pr")?.asText()?.ifBlank { null } + + if (pr == null) { + onProgress(0.0f) + val reason = lnInvoice?.get("reason")?.asText()?.ifBlank { null } + + if (reason != null) { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user, + lnAddress, + reason, + ), + ) + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, + lnAddress, + ), + ) + } + } + + // Forces LN Invoice amount to be the requested amount. + val expectedAmountInSats = + BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() + + val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) + + if (invoiceAmount.toLong() != expectedAmountInSats) { + onProgress(0.0f) + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string.incorrect_invoice_amount_sats_from_it_should_have_been, + invoiceAmount.toLong().toString(), + lnAddress, + expectedAmountInSats.toString(), + ), + ) + } + + onProgress(0.7f) + + return pr } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index c3bfc9bfd7..a9ae46535a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import android.util.Log import com.fonfon.kgeohash.GeoHash import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision @@ -77,7 +78,7 @@ class LocationState( }.onEach { latestLocation = it }.catch { e -> - e.printStackTrace() + Log.w("GeohashStateFlow", "Exception in the flow", e) latestLocation = LocationResult.LackPermission emit(LocationResult.LackPermission) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt index dc6ed4fd8e..27e0c3120a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt @@ -84,6 +84,7 @@ class ReverseGeolocation { 1, ) } catch (e: IOException) { + Log.w("ReverseGeolocation", "IO Error", e) e.printStackTrace() return null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index d5572ffe4c..625c6cf2d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -24,7 +24,6 @@ import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.core.content.ContextCompat -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AccountSettings @@ -43,8 +42,6 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent @@ -86,22 +83,13 @@ class EventNotificationConsumer( pushWrappedEvent: GiftWrapEvent, account: AccountSettings, ) { - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - val signer = account.createSigner() - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } + val signer = account.createSigner(applicationContext.contentResolver) - pushWrappedEvent.unwrapThrowing(signer) { notificationEvent -> - consumeNotificationEvent(notificationEvent, signer, account) - } + val notificationEvent = pushWrappedEvent.unwrapThrowing(signer) + consumeNotificationEvent(notificationEvent, signer, account) } - fun consumeNotificationEvent( + suspend fun consumeNotificationEvent( notificationEvent: Event, signer: NostrSigner, account: AccountSettings, @@ -110,22 +98,17 @@ class EventNotificationConsumer( Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed") if (!consumed) { Log.d(TAG, "New Notification was verified") - unwrapAndConsume(notificationEvent, signer) { innerEvent -> - if (!notificationManager().areNotificationsEnabled()) return@unwrapAndConsume + if (!notificationManager().areNotificationsEnabled()) return + Log.d(TAG, "Notifications are enabled") - Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}") - if (innerEvent is PrivateDmEvent) { - Log.d(TAG, "New Nip-04 DM to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is LnZapEvent) { - Log.d(TAG, "New Zap to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is ChatMessageEvent) { - Log.d(TAG, "New ChatMessage to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is ChatMessageEncryptedFileHeaderEvent) { - Log.d(TAG, "New ChatMessage File to Notify") - notify(innerEvent, signer, account) + unwrapAndConsume(notificationEvent, signer)?.let { innerEvent -> + Log.d(TAG, "Unwrapped consume ${innerEvent.javaClass.simpleName}") + + when (innerEvent) { + is PrivateDmEvent -> notify(innerEvent, signer, account) + is LnZapEvent -> notify(innerEvent, signer, account) + is ChatMessageEvent -> notify(innerEvent, signer, account) + is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, signer, account) } } } @@ -144,16 +127,7 @@ class EventNotificationConsumer( LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> Log.d(TAG, "New Notification Testing if for ${it.npub}") try { - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - val signer = acc.createSigner() - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } - + val signer = acc.createSigner(applicationContext.contentResolver) consumeNotificationEvent(event, signer, acc) matchAccount = true } catch (e: Exception) { @@ -165,42 +139,45 @@ class EventNotificationConsumer( } } - private fun unwrapAndConsume( + private suspend fun unwrapAndConsume( event: Event, signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - if (LocalCache.hasConsumed(event)) return + ): Event? { + if (LocalCache.hasConsumed(event)) return null - when (event) { + return when (event) { is GiftWrapEvent -> { if (LocalCache.justConsume(event, null, false)) { // new event - event.unwrap(signer) { - // clear the encrypted payload to save memory - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + val inner = event.unwrapThrowing(signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() - unwrapAndConsume(it, signer, onReady) - } + unwrapAndConsume(inner, signer) + } else { + null } } is SealedRumorEvent -> { if (LocalCache.justConsume(event, null, false)) { // new event - event.unseal(signer) { - // clear the encrypted payload to save memory - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + val inner = event.unsealThrowing(signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() - // this is not verifiable - if (LocalCache.justConsume(it, null, true)) { - onReady(it) - } + // this is not verifiable + if (LocalCache.justConsume(inner, null, true)) { + event + } else { + null } + } else { + null } } else -> { LocalCache.justConsume(event, null, false) - onReady(event) + event } } } @@ -210,6 +187,7 @@ class EventNotificationConsumer( signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New ChatMessage File to Notify") if ( // old event being re-broadcasted event.createdAt > TimeUtils.fifteenMinutesAgo() && @@ -217,7 +195,7 @@ class EventNotificationConsumer( event.pubKey != signer.pubKey ) { // from the user Log.d(TAG, "Notifying") - val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) val chatNote = LocalCache.getNoteIfExists(event.id) ?: return val chatRoom = event.chatroomKey(signer.pubKey) @@ -225,8 +203,7 @@ class EventNotificationConsumer( val isKnownRoom = ( - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) + chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) ) if (isKnownRoom) { @@ -255,6 +232,7 @@ class EventNotificationConsumer( signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New ChatMessage to Notify") if ( // old event being re-broadcasted event.createdAt > TimeUtils.fifteenMinutesAgo() && @@ -262,17 +240,13 @@ class EventNotificationConsumer( event.pubKey != signer.pubKey ) { // from the user Log.d(TAG, "Notifying") - val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) val chatNote = LocalCache.getNoteIfExists(event.id) ?: return val chatRoom = event.chatroomKey(signer.pubKey) val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return - val isKnownRoom = - ( - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) - ) + val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) if (isKnownRoom) { val content = chatNote.event?.content ?: "" @@ -293,13 +267,14 @@ class EventNotificationConsumer( } } - private fun notify( + private suspend fun notify( event: PrivateDmEvent, signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New Nip-04 DM to Notify") val note = LocalCache.getNoteIfExists(event.id) ?: return - val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) // old event being re-broadcast if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return @@ -309,13 +284,11 @@ class EventNotificationConsumer( val chatRoom = event.chatroomKey(signer.pubKey) - val isKnownRoom = - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) + val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) if (isKnownRoom) { note.author?.let { - decryptContent(note, signer) { content -> + decryptContent(note, signer)?.let { content -> val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() val noteUri = note.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() @@ -327,47 +300,44 @@ class EventNotificationConsumer( } } - fun decryptZapContentAuthor( - note: Note, + suspend fun decryptZapContentAuthor( + event: LnZapRequestEvent, signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - val event = note.event - if (event is LnZapRequestEvent) { - if (event.isPrivateZap()) { - event.decryptPrivateZap(signer) { onReady(it) } - } else { - onReady(event) - } - } - } - - fun decryptContent( - note: Note, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val event = note.event - if (event is PrivateDmEvent) { - event.plainContent(signer, onReady) - } else if (event is LnZapRequestEvent) { - decryptZapContentAuthor(note, signer) { onReady(it.content) } - } else if (event is DraftEvent) { - event.cachedDraft(signer) { - onReady(it.content) - } + ): Event? = + if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) { + signer.decryptZapEvent(event) } else { - event?.content?.let { onReady(it) } + event + } + + suspend fun decryptContent( + note: Note, + signer: NostrSigner, + ): String? { + val event = note.event + when (event) { + is PrivateDmEvent -> { + return event.decryptContent(signer) + } + + is LnZapRequestEvent -> { + return decryptZapContentAuthor(event, signer)?.content + } + + else -> { + return event?.content + } } } - private fun notify( + private suspend fun notify( event: LnZapEvent, signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New Zap to Notify") Log.d(TAG, "Notify Start ${event.toNostrUri()}") - val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return + LocalCache.getNoteIfExists(event.id) ?: return Log.d(TAG, "Notify Not Notified Yet") @@ -377,8 +347,7 @@ class EventNotificationConsumer( Log.d(TAG, "Notify Not an old event") val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - val noteZapped = - event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped") @@ -392,17 +361,17 @@ class EventNotificationConsumer( Log.d(TAG, "Notify Amount $amount") (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> - decryptZapContentAuthor(noteZapRequest, signer) { + decryptZapContentAuthor(event, signer)?.let { decryptedEvent -> Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}") - val author = LocalCache.getOrCreateUser(it.pubKey) - val senderInfo = Pair(author, it.content.ifBlank { null }) + val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey) + val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null }) if (noteZapped.event?.content != null) { - decryptContent(noteZapped, signer) { + decryptContent(noteZapped, signer)?.let { decrypted -> Log.d(TAG, "Notify Decrypted if Private Note") - val zappedContent = it.split("\n").get(0) + val zappedContent = decrypted.split("\n")[0] val user = senderInfo.first.toBestDisplayName() var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 5be226aeb0..43c2279b51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -25,23 +25,24 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.launchAndWaitAll +import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.tryAndWait +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal +import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import kotlin.coroutines.resume +import okhttp3.coroutines.executeAsync class RegisterAccounts( private val accounts: List, private val client: (String) -> OkHttpClient, ) { + @Suppress("SENSELESS_COMPARISON") val tag = if (BuildConfig.FLAVOR == "play") { "RegisterAccounts FirebaseMsgService" @@ -51,70 +52,54 @@ class RegisterAccounts( private suspend fun signAllAuths( notificationToken: String, - remainingTos: List>>, - output: MutableList, - onReady: (List) -> Unit, - ) { + remainingTos: List, + ): List { if (remainingTos.isEmpty()) { - onReady(output) - return + return emptyList() } - launchAndWaitAll(remainingTos) { accountRelayPair -> - val result = - tryAndWait { continuation -> - val signer = accountRelayPair.first.createSigner() - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } - - RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> - continuation.resume(result) - } - } - - if (result != null) { - output.add(result) - } + return mapNotNullAsync(remainingTos) { info -> + val signer = info.accountSettings.createSigner(Amethyst.instance.contentResolver) + RelayAuthEvent.create(info.relays, notificationToken, signer) } - - onReady(output) } + class Registration( + val accountSettings: AccountSettings, + val relays: List, + ) + // creates proof that it controls all accounts private suspend fun signEventsToProveControlOfAccounts( accounts: List, notificationToken: String, - onReady: (List) -> Unit, - ) { + ): List { val readyToSend = accounts - .mapNotNull { - Log.d(tag, "Register Account ${it.npub}") + .mapNotNull { account -> + if (account.hasPrivKey || account.loggedInWithExternalSigner) { + Log.d(tag, "Register Account ${account.npub}") - val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) - if (acc != null && acc.isWriteable()) { - val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList() + val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(account.npub) + if (acc != null && acc.isWriteable()) { + val nip65Read = acc.backupNIP65RelayList?.readRelaysNorm() ?: emptyList() + val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() - Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}") + if (isDebug) { + val readRelays = nip65Read.joinToString(", ") { it.url } + Log.d(tag, "Register Account ${account.npub} NIP65 Reads $readRelays") - val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() + val dmRelays = nip17Read.joinToString(", ") { it.url } + Log.d(tag, "Register Account ${account.npub} NIP17 Reads $dmRelays") + } - Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}") + val relays = (nip65Read + nip17Read) - val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList() - - Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}") - - val relays = (nip65Read + nip17Read + readKind3Relays) - - if (relays.isNotEmpty()) { - Pair(acc, relays) + if (relays.isNotEmpty()) { + Registration(acc, relays) + } else { + null + } } else { null } @@ -123,16 +108,10 @@ class RegisterAccounts( } } - val listOfAuthEvents = mutableListOf() - signAllAuths( - notificationToken, - readyToSend, - listOfAuthEvents, - onReady, - ) + return signAllAuths(notificationToken, readyToSend) } - fun postRegistrationEvent(events: List) { + suspend fun postRegistrationEvent(events: List) { val jsonObject = """{ "events": [ ${events.joinToString(", ") { it.toJson() }} ] @@ -146,19 +125,21 @@ class RegisterAccounts( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .post(body) .build() - val isSucess = client(url).newCall(request).execute().use { it.isSuccessful } - Log.i(tag, "Server registration $isSucess") + val client = client(url) + + client.newCall(request).executeAsync().use { response -> + Log.i(tag, "Server registration ${response.isSuccessful}") + } } suspend fun go(notificationToken: String) { if (notificationToken.isNotEmpty()) { withContext(Dispatchers.IO) { - signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } + postRegistrationEvent(signEventsToProveControlOfAccounts(accounts, notificationToken)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 6268971c50..4ffa9f7b75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -39,7 +39,7 @@ class DualHttpClientManager( ) { val factory = OkHttpClientFactory(keyCache) - private val defaultHttpClient: StateFlow = + val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> factory.buildHttpClient(proxy, mobile, userAgent) }.stateIn( @@ -48,7 +48,7 @@ class DualHttpClientManager( factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value, userAgent), ) - private val defaultHttpClientWithoutProxy: StateFlow = + val defaultHttpClientWithoutProxy: StateFlow = isMobileDataProvider .map { mobile -> factory.buildHttpClient(mobile, userAgent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index bd3e2aec89..bf87bdbcbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -20,25 +20,49 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import okhttp3.Dispatcher import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration class OkHttpClientFactory( - val keyCache: EncryptionKeyCache, + keyCache: EncryptionKeyCache, ) { companion object { - // by picking a random proxy port, the connection will fail as it shouold. + // by picking a random proxy port, the connection will fail as it should. const val DEFAULT_SOCKS_PORT: Int = 9050 const val DEFAULT_IS_MOBILE: Boolean = false const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 } + val logging = LoggingInterceptor() + val keyDecryptor = EncryptedBlobInterceptor(keyCache) + + val myDispatcher = + Dispatcher().apply { + maxRequests = 512 + } + + /* + DEBUG OK HTTP connections here. + init { + if (isDebug) { + GlobalScope.launch(Dispatchers.IO) { + while (true) { + Log.d("OkHttpClientFactory", "Active threads ${myDispatcher.runningCallsCount()}") + delay(5000) + } + } + } + } + */ + private val rootClient = OkHttpClient .Builder() + .dispatcher(myDispatcher) .followRedirects(true) .followSslRedirects(true) .build() @@ -57,8 +81,8 @@ class OkHttpClientFactory( .connectTimeout(duration) .writeTimeout(duration) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) - .addNetworkInterceptor(LoggingInterceptor()) - .addNetworkInterceptor(EncryptedBlobInterceptor(keyCache)) + .addNetworkInterceptor(logging) + .addNetworkInterceptor(keyDecryptor) .build() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 51f8dcfdd2..476f17d12f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -20,27 +20,49 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response class OkHttpWebSocket( - val url: String, - val forceProxy: Boolean, - val httpClient: (url: String, forceProxy: Boolean) -> OkHttpClient, + val url: NormalizedRelayUrl, + val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient, val out: WebSocketListener, ) : WebSocket { private val listener = OkHttpWebsocketListener() + private var usingOkHttp: OkHttpClient? = null private var socket: okhttp3.WebSocket? = null - fun buildRequest() = Request.Builder().url(url.trim()).build() + fun buildRequest() = Request.Builder().url(url.url).build() + + override fun needsReconnect(): Boolean { + val myUsingOkHttp = usingOkHttp + if (myUsingOkHttp == null) return true + + val currentOkHttp = httpClient(url) + + val usingProxy = myUsingOkHttp.proxy + val currentProxy = currentOkHttp.proxy + + if (usingProxy != null && currentProxy != null && usingProxy != currentProxy) return true + if (usingProxy == null && currentProxy != null) return true + if (usingProxy != null && currentProxy == null) return true + + if (currentOkHttp.readTimeoutMillis != myUsingOkHttp.readTimeoutMillis) return true + if (currentOkHttp.writeTimeoutMillis != myUsingOkHttp.writeTimeoutMillis) return true + if (currentOkHttp.connectTimeoutMillis != myUsingOkHttp.connectTimeoutMillis) return true + if (currentOkHttp.callTimeoutMillis != myUsingOkHttp.callTimeoutMillis) return true + + return false + } override fun connect() { - socket = httpClient(url, forceProxy).newWebSocket(buildRequest(), listener) + usingOkHttp = httpClient(url) + socket = usingOkHttp?.newWebSocket(buildRequest(), listener) } inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { @@ -49,7 +71,7 @@ class OkHttpWebSocket( response: Response, ) = out.onOpen( response.receivedResponseAtMillis - response.sentRequestAtMillis, - response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, ) override fun onMessage( @@ -73,30 +95,21 @@ class OkHttpWebSocket( webSocket: okhttp3.WebSocket, t: Throwable, response: Response?, - ) = out.onFailure(t, response?.message) + ) = out.onFailure(t, response?.code, response?.message) } class Builder( - val forceProxy: Boolean, - val httpClient: (String, Boolean) -> OkHttpClient, + val httpClient: (NormalizedRelayUrl) -> OkHttpClient, ) : WebsocketBuilder { // Called when connecting. override fun build( - url: String, + url: NormalizedRelayUrl, out: WebSocketListener, - ) = OkHttpWebSocket(url, forceProxy, httpClient, out) + ) = OkHttpWebSocket(url, httpClient, out) } - class BuilderFactory( - val httpClient: (String, Boolean) -> OkHttpClient, - ) : WebsocketBuilderFactory { - override fun build( - url: String, - forceProxy: Boolean, - ) = Builder(forceProxy, httpClient) - } - - override fun cancel() { + override fun disconnect() { + // uses cancel to kill the SEND stack that might be waiting socket?.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt new file mode 100644 index 0000000000..37af51d960 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation +import com.vitorpamplona.amethyst.model.torState.TorRelaySettings +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class ProxySettingsAnchor { + val flow: MutableStateFlow> = + MutableStateFlow( + MutableStateFlow( + TorRelayEvaluation( + torSettings = TorRelaySettings(), + trustedRelayList = emptySet(), + dmRelayList = emptySet(), + ), + ), + ) + + var useProxy: (NormalizedRelayUrl) -> Boolean = { flow.value.value.useTor(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt index fec1f9e15c..7ec04b0e9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.ots import android.util.Log import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException @@ -51,7 +50,6 @@ class OkHttpBitcoinExplorer( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/json") .url(url) .get() @@ -95,7 +93,6 @@ class OkHttpBitcoinExplorer( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt index caf0f73b87..b8af7b7efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.ots -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp @@ -60,7 +59,6 @@ class OkHttpCalendar( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) @@ -112,7 +110,6 @@ class OkHttpCalendar( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt index 0feae9e871..c412fc0c00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.ots -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp @@ -45,7 +44,6 @@ class OkHttpCalendarAsyncSubmit( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpOtsResolverBuilder.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpOtsResolverBuilder.kt index 5925ea6572..d858cdd6b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpOtsResolverBuilder.kt @@ -22,8 +22,13 @@ package com.vitorpamplona.amethyst.service.ots import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder -class OtsResolverBuilder { +class OkHttpOtsResolverBuilder( + val okHttpClients: DualHttpClientManager, + val shouldUseTorForUrl: (String) -> Boolean, + val cache: OtsBlockHeightCache, +) : OtsResolverBuilder { fun getAPI(usingTor: Boolean) = if (usingTor) { OkHttpBitcoinExplorer.MEMPOOL_API_URL @@ -31,22 +36,20 @@ class OtsResolverBuilder { OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL } - fun build( - okHttpClients: DualHttpClientManager, - shouldUseTorForUrl: (String) -> Boolean, - cache: OtsBlockHeightCache, - ): OtsResolver { + override fun build(): OtsResolver { val shouldUseTor = shouldUseTorForUrl(OkHttpBitcoinExplorer.MEMPOOL_API_URL) return OtsResolver( - OkHttpBitcoinExplorer( - getAPI(shouldUseTor), - okHttpClients.getHttpClient(shouldUseTor), - cache, - ), - OkHttpCalendarBuilder { - okHttpClients.getHttpClient(shouldUseTorForUrl(it)) - }, + explorer = + OkHttpBitcoinExplorer( + baseAPI = getAPI(usingTor = shouldUseTor), + client = okHttpClients.getHttpClient(shouldUseTor), + cache = cache, + ), + calendarBuilder = + OkHttpCalendarBuilder { + okHttpClients.getHttpClient(shouldUseTorForUrl(it)) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index fa78b283d8..19fcc64821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.playback.composable +import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -67,23 +68,30 @@ fun ControlWhenPlayerIsActive( // Keeps the screen on while playing and viewing videos. DisposableEffect(key1 = controller, key2 = view) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (view.keepScreenOn != isPlaying) { - view.keepScreenOn = isPlaying - } - } - } + val listener = PlayerEventListener(view) controller.addListener(listener) onDispose { - if (view.keepScreenOn) { - view.keepScreenOn = false - } controller.removeListener(listener) + listener.destroy() + } + } +} + +class PlayerEventListener( + val view: View, +) : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (view.keepScreenOn != isPlaying) { + view.keepScreenOn = isPlaying + } + } + + fun destroy() { + if (view.keepScreenOn) { + view.keepScreenOn = false } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 52850fd07e..f264bc9f53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -54,6 +54,10 @@ fun GetVideoController( val scope = rememberCoroutineScope() // Prepares a VideoPlayer from the foreground service. + // + // TODO: Review this code because a new Disposable Effect can run + // before the onDispose of the previous composable and the onDispose + // sometimes affects the new variables, not the old ones. DisposableEffect(key1 = mediaItem.src.videoUri) { // If it is not null, the user might have come back from a playing video, like clicking on // the notification of the video player. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 458ad57f42..1a9e667062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCon import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag @Composable @OptIn(UnstableApi::class) @@ -50,7 +49,7 @@ fun RenderVideoPlayer( thumbData: VideoThumb?, showControls: Boolean = true, contentScale: ContentScale, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, borderModifier: Modifier, videoModifier: Modifier, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 011d825048..8b31f98922 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -42,9 +43,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +@Immutable +class WaveformData( + val wave: List, +) + @Composable fun VideoView( videoUri: String, @@ -54,7 +59,7 @@ fun VideoView( roundedCorner: Boolean, gallery: Boolean = false, contentScale: ContentScale, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, artworkUri: String? = null, authorName: String? = null, dimensions: DimensionTag? = null, @@ -85,7 +90,7 @@ fun VideoView( thumb: VideoThumb? = null, borderModifier: Modifier, contentScale: ContentScale, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, artworkUri: String? = null, authorName: String? = null, dimensions: DimensionTag? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 61eac519f2..03aca3d3d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag public val DEFAULT_MUTED_SETTING = mutableStateOf(true) @@ -43,7 +42,7 @@ fun VideoViewInner( showControls: Boolean = true, contentScale: ContentScale, borderModifier: Modifier, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt index 2749a52e1d..e2f95fadbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt @@ -20,19 +20,16 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls -import android.content.Context import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity -import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.components.ShareImageAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -70,7 +67,7 @@ fun RenderControlButtons( if (!isLiveStreaming(mediaData.videoUri)) { AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context -> - saveMediaToGalleryInner(mediaData.videoUri, mediaData.mimeType, context, accountViewModel) + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) } AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle -> @@ -82,23 +79,3 @@ fun RenderControlButtons( } } } - -private fun saveMediaToGalleryInner( - videoUri: String?, - mimeType: String?, - localContext: Context, - accountViewModel: AccountViewModel, -) { - MediaSaverToDisk.saveDownloadingIfNeeded( - videoUri = videoUri, - okHttpClient = accountViewModel::okHttpClientForVideo, - mimeType = mimeType, - localContext = localContext, - onSuccess = { - accountViewModel.toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery) - }, - onError = { - accountViewModel.toastManager.toast(R.string.failed_to_save_the_video, null, it) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt index 57da6b3119..c0ef0c8659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt @@ -38,15 +38,15 @@ import androidx.media3.common.Player import androidx.media3.session.MediaController import com.linc.audiowaveform.infiniteLinearGradient import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow @Composable fun Waveform( - waveform: WaveformTag, + waveform: WaveformData, mediaControllerState: MediaControllerState, modifier: Modifier, ) { @@ -56,21 +56,25 @@ fun Waveform( val restartFlow = remember { mutableIntStateOf(0) } + val myController = mediaControllerState.controller + // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = mediaControllerState.controller) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (isPlaying) { - restartFlow.intValue += 1 + if (myController != null) { + DisposableEffect(key1 = myController) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.intValue += 1 + } } } - } - mediaControllerState.controller?.addListener(listener) - onDispose { mediaControllerState.controller?.removeListener(listener) } + myController.addListener(listener) + onDispose { myController.removeListener(listener) } + } } LaunchedEffect(key1 = restartFlow.intValue) { @@ -90,7 +94,7 @@ private fun pollCurrentDuration(controller: MediaController) = @Composable fun DrawWaveform( - waveform: WaveformTag, + waveform: WaveformData, waveformProgress: MutableFloatState, modifier: Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt index edddaf1ac1..1649d665e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt @@ -39,7 +39,7 @@ object BackgroundMedia { fun removeBackgroundControllerAndReleaseIt() { bgInstance.value?.let { PlaybackServiceClient.removeController(it) - clearBackground() + bgInstance.tryEmit(null) } } @@ -47,7 +47,9 @@ object BackgroundMedia { bgInstance.tryEmit(mediaControllerState) } - fun clearBackground() { - bgInstance.tryEmit(null) + fun clearBackground(mediaControllerState: MediaControllerState) { + if (bgInstance.value == mediaControllerState) { + bgInstance.tryEmit(null) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt index 42ebcff9ee..c2a2008b66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt @@ -58,9 +58,8 @@ fun rememberIsInPipMode(): Boolean { Consumer { info -> pipMode = info.isInPictureInPictureMode } - activity.addOnPictureInPictureModeChangedListener( - observer, - ) + + activity.addOnPictureInPictureModeChangedListener(observer) onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } } return pipMode diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 8a1ff7d231..3cde5e550c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -67,11 +67,6 @@ class PipVideoActivity : ComponentActivity() { super.finish() } - override fun onBackPressed() { - super.onBackPressed() - finishAndRemoveTask() - } - override fun onUserLeaveHint() { super.onUserLeaveHint() finishAndRemoveTask() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 5a856db04c..168e03897e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -81,7 +81,7 @@ fun PipVideo(controller: MediaControllerState) { DisposableEffect(controller) { BackgroundMedia.switchKeepPlaying(controller) onDispose { - BackgroundMedia.clearBackground() + BackgroundMedia.clearBackground(controller) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 4508340ff9..3d25f38866 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.service.previews -import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class UrlPreview { suspend fun fetch( @@ -52,24 +52,29 @@ class UrlPreview { .url(url) .get() .build() - okHttpClient(url).newCall(request).execute().use { - checkNotInMainThread() - if (it.isSuccessful) { - val mimeType = - it.headers["Content-Type"]?.toMediaType() - ?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}") - if (mimeType.type == "text" && mimeType.subtype == "html") { - val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType)) - UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) - } else if (mimeType.type == "image") { - UrlInfoItem(url, image = url, mimeType = mimeType.toString()) - } else if (mimeType.type == "video") { - UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + + val client = okHttpClient(url) + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val mimeType = + response.headers["Content-Type"]?.toMediaType() + ?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}") + if (mimeType.type == "text" && mimeType.subtype == "html") { + val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType) + val data = OpenGraphParser().extractUrlInfo(metaTags) + UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) + } else if (mimeType.type == "image") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else if (mimeType.type == "video") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else { + throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") + } } else { - throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") + throw IllegalArgumentException("Website returned: " + response.code) } - } else { - throw IllegalArgumentException("Website returned: " + it.code) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt index 168419aadb..0371fc4fe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -21,15 +21,18 @@ package com.vitorpamplona.amethyst.service.relayClient import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.datasources.EventCollector -import com.vitorpamplona.ammolite.relays.datasources.RelayInsertConfirmationCollector +import com.vitorpamplona.amethyst.model.LocalCache.markAsSeen import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope class CacheClientConnector( val client: NostrClient, val cache: LocalCache, + val scope: CoroutineScope, ) { val receiver = EventCollector(client) { event, relay -> @@ -38,8 +41,8 @@ class CacheClientConnector( val confirmationWatcher = RelayInsertConfirmationCollector(client) { eventId, relay -> - cache.markAsSeen(eventId, relay.brief) - markAsSeen(eventId, relay.brief) + cache.markAsSeen(eventId, relay.url) + markAsSeen(eventId, relay.url) } fun destroy() { @@ -49,6 +52,6 @@ class CacheClientConnector( private fun markAsSeen( eventId: HexKey, - relay: RelayBriefInfoCache.RelayBriefInfo, - ) = LocalCache.getNoteIfExists(eventId)?.addRelay(relay) + info: NormalizedRelayUrl, + ) = LocalCache.getNoteIfExists(eventId)?.addRelay(info) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt index 03416ce0d7..6b33107256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onNotify messages from the relay @@ -36,20 +37,20 @@ class RelayLogger( } private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { /** A new message was received */ override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { - Log.d(TAG, "Relay onEVENT ${relay.url} ($subscriptionId - $afterEOSE) ${event.toJson()}") + Log.d(TAG, "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") } override fun onSend( - relay: Relay, + relay: IRelayClient, msg: String, success: Boolean, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt new file mode 100644 index 0000000000..fdc54672d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import android.util.Log +import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager +import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class RelayProxyClientConnector( + val torProxySettingsAnchor: ProxySettingsAnchor, + val okHttpClients: DualHttpClientManager, + val connManager: ConnectivityManager, + val client: NostrClient, + val scope: CoroutineScope, +) { + @OptIn(FlowPreview::class) + val relayServices = + combine( + torProxySettingsAnchor.flow, + okHttpClients.defaultHttpClient, + okHttpClients.defaultHttpClientWithoutProxy, + connManager.status, + ) { torSettings, torConnection, clearConnection, connectivity -> + torSettings.hashCode() + torConnection.hashCode() + clearConnection.hashCode() + connectivity.hashCode() + }.debounce(100) + .onEach { + Log.d("ManageRelayServices", "Relay Services have changed") + client.reconnect(true) + }.onStart { + Log.d("ManageRelayServices", "Resuming Relay Services") + client.connect() + }.onCompletion { + Log.d("ManageRelayServices", "Pausing Relay Services") + client.disconnect() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + null, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt deleted file mode 100644 index d74be74973..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.relayClient - -import android.util.Log -import com.vitorpamplona.amethyst.service.relayClient.RelaySpeedLogger.Companion.TAG -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.utils.LargeCache -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.concurrent.timer - -@OptIn(ExperimentalAtomicApi::class) -class KindGroup( - var count: AtomicInteger = AtomicInteger(0), - var memory: AtomicLong = AtomicLong(0L), - val subs: LargeCache = LargeCache(), - val relays: LargeCache = LargeCache(), -) { - companion object { - const val MB: Long = 1024 - } - - fun increment( - mem: Long, - subId: String, - relayUrl: String, - ) { - count.incrementAndGet() - memory.addAndGet(mem) - - val subStats = subs.get(subId) - if (subStats != null) { - subStats.incrementAndGet() - } else { - subs.put(subId, AtomicInteger(1)) - } - - val relayStats = relays.get(relayUrl) - if (relayStats != null) { - relayStats.incrementAndGet() - } else { - relays.put(relayUrl, AtomicInteger(1)) - } - } - - fun reset() { - count.set(0) - memory.set(0L) - subs.forEach { key, value -> value.set(0) } - relays.forEach { key, value -> value.set(0) } - } - - fun printSubs() = subs.joinToString(", ") { key, value -> if (value.get() > 0) "$key ($value)" else "" } - - fun printRelays() = relays.joinToString(", ") { key, value -> if (value.get() > 0) "${key.removePrefix("wss://").removeSuffix("/")} ($value)" else "" } - - override fun toString() = "(${count.get()} - ${memory.get().div(MB)}kb); ${printSubs()}; ${printRelays()}" -} - -class FrameStat { - var eventCount = AtomicInteger(0) - var kinds = LargeCache() - - fun increment( - kind: Int, - subId: String, - relayUrl: String, - memory: Long, - ) { - eventCount.incrementAndGet() - - val kindGroup = kinds.get(kind) - if (kindGroup != null) { - kindGroup.increment(memory, subId, relayUrl) - } else { - val group = KindGroup() - group.increment(memory, subId, relayUrl) - kinds.put(kind, group) - } - } - - fun hasAnything() = eventCount.get() > 0 - - fun reset() { - eventCount.set(0) - kinds.forEach { key, value -> value.reset() } - } - - fun log() { - Log.d(TAG, "Events Per Second: ${eventCount.get()}") - kinds.forEach { key, value -> - if (value.count.get() > 0) { - Log.d(TAG, "-- Kind $key $value") - } - } - } - - // Initialize the timer in the constructor. This ensures it starts when the - // ResettableCounter object is created. - init { - // Use a timer to reset the counter every second. - timer(name = "EventsPerSecondCounter", period = 1000, daemon = true) { - if (hasAnything()) { - log() - reset() - } - } - } -} - -/** - * Listens to NostrClient's onNotify messages from the relay - */ -class RelaySpeedLogger( - val client: NostrClient, -) { - companion object { - val TAG = RelaySpeedLogger::class.java.simpleName - } - - var current = FrameStat() - - private val clientListener = - object : NostrClient.Listener { - /** A new message was received */ - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - current.increment(event.kind, subscriptionId, relay.url, event.countMemory()) - } - } - - init { - Log.d(TAG, "Init, Subscribe") - client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d(TAG, "Destroy, Unsubscribe") - client.unsubscribe(clientListener) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index d0d56d6766..b880576921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -23,8 +23,9 @@ 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 com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayAuthenticator +import kotlinx.coroutines.CoroutineScope class ScreenAuthAccount( val account: Account, @@ -32,11 +33,12 @@ class ScreenAuthAccount( class AuthCoordinator( client: NostrClient, + scope: CoroutineScope, ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } val receiver = - RelayAuthenticator(client) { challenge, relay -> + RelayAuthenticator(client, scope) { challenge, relay -> authWithAccounts.distinct().forEach { it.sendAuthEvent(relay, challenge) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index f9389cb07a..4c9bb3f66f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -34,14 +34,8 @@ abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControl fun subscribe(query: T?) { if (query == null) return - val wasEmpty = composeSubscriptions.isEmpty() - composeSubscriptions.put(query, query) - if (wasEmpty) { - start() - } - invalidateKeys() } @@ -52,15 +46,7 @@ abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControl composeSubscriptions.remove(query) invalidateKeys() - - if (composeSubscriptions.isEmpty()) { - stop() - } } fun allKeys() = composeSubscriptions.keys - - fun forEachSubscriber(action: (T) -> Unit) { - composeSubscriptions.keys.forEach(action) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt index 6a1d54fd19..479bde071d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt @@ -21,10 +21,6 @@ package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers interface ComposeSubscriptionManagerControls { - fun start() - - fun stop() - fun invalidateKeys() fun invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index a9520a230c..9966f901d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -45,8 +45,6 @@ abstract class MutableComposeSubscriptionManager( fun subscribe(query: T?) { if (query == null) return - val wasEmpty = composeSubscriptions.isEmpty() - composeSubscriptions[query]?.cancel() composeSubscriptions[query] = scope.launch { @@ -55,10 +53,6 @@ abstract class MutableComposeSubscriptionManager( } } - if (wasEmpty) { - start() - } - invalidateKeys() } @@ -70,10 +64,6 @@ abstract class MutableComposeSubscriptionManager( composeSubscriptions.remove(query) invalidateKeys() - - if (composeSubscriptions.isEmpty()) { - stop() - } } fun allKeys() = composeSubscriptions.keys diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt index 729864eee2..ea0c7db732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt @@ -22,49 +22,53 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import android.util.Log import com.vitorpamplona.amethyst.isDebug -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.datasources.SubscriptionController +import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers abstract class BaseEoseManager( val client: NostrClient, val allKeys: () -> Set, ) { - val orchestrator = - SubscriptionController(client) { - if (isDebug) Log.d("${this.javaClass.simpleName}", "Updating Subscriptions") - updateSubscriptions(allKeys()) - } + protected val logTag: String = this.javaClass.simpleName + + private val orchestrator = SubscriptionController(client) abstract fun updateSubscriptions(keys: Set) - fun printStats() = orchestrator.printStats(this.javaClass.simpleName) + fun printStats() = orchestrator.printStats(logTag) - fun invalidateFilters() = orchestrator.invalidateFilters() + fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId() - fun start() { - orchestrator.start() - if (isDebug) { - Log.d("${this.javaClass.simpleName}", "Start") + fun getSubscription(subId: String) = orchestrator.getSub(subId) + + fun requestNewSubscription(onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null) = orchestrator.requestNewSubscription(newSubscriptionId(), onEOSE) + + fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) + + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) + + fun invalidateFilters() { + bundler.invalidate { + forceInvalidate() } } - fun stop() { - orchestrator.stop() - if (isDebug) { - Log.d("${this.javaClass.simpleName}", "Stop") - } + fun forceInvalidate() { + updateSubscriptions(allKeys()) + + orchestrator.updateRelays() } fun destroy() { + bundler.cancel() orchestrator.destroy() if (isDebug) { - Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") - } - } - - fun init() { - if (isDebug) { - Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + Log.d(logTag, "Destroy, Unsubscribe") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index d31b5a7f70..dfb326b121 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers -import com.vitorpamplona.amethyst.service.relays.EOSEFollowList -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.distinctBy +import com.vitorpamplona.amethyst.service.relays.EOSEByKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * This query type creates a new relay subscription for every SubID.id() @@ -35,22 +36,22 @@ import kotlin.collections.distinctBy * This class keeps EOSEs for each SubID.id() for as long as possible and * shares all EOSEs among all users. */ -abstract class PerUniqueIdEoseManager( +abstract class PerUniqueIdEoseManager( client: NostrClient, allKeys: () -> Set, val invalidateAfterEose: Boolean = false, ) : BaseEoseManager(client, allKeys) { // long term EOSE cache - private val latestEOSEs = EOSEFollowList() + private val latestEOSEs = EOSEByKey() // map between each query Id and each subscription id - private val userSubscriptionMap = mutableMapOf() + private val userSubscriptionMap = mutableMapOf() fun since(key: T) = latestEOSEs.since(id(key)) fun newEose( key: T, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { latestEOSEs.newEose(id(key), relayUrl, time) @@ -60,50 +61,50 @@ abstract class PerUniqueIdEoseManager( } open fun newSub(key: T): Subscription = - orchestrator.requestNewSubscription { time, relayUrl -> + requestNewSubscription { time, relayUrl -> newEose(key, relayUrl, time) } open fun endSub( - key: String, + key: U, subId: String, ) { - orchestrator.dismissSubscription(subId) + dismissSubscription(subId) + userSubscriptionMap.remove(key) } fun findOrCreateSubFor(key: T): Subscription { val id = id(key) - var subId = userSubscriptionMap[id] + val subId = userSubscriptionMap[id] return if (subId == null) { newSub(key).also { userSubscriptionMap[id] = it.id } } else { - orchestrator.getSub(subId) ?: newSub(key).also { userSubscriptionMap[id] = it.id } + getSubscription(subId) ?: newSub(key).also { userSubscriptionMap[id] = it.id } } } override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { id(it) } - val updated = mutableSetOf() + val updated = mutableSetOf() uniqueSubscribedAccounts.forEach { val mainKey = id(it) - findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) updated.add(mainKey) } - userSubscriptionMap.forEach { - if (it.key !in updated) { - endSub(it.key, it.value) - } + userSubscriptionMap.filter { it.key !in updated }.forEach { + endSub(it.key, it.value) } } abstract fun updateFilter( key: T, - since: Map?, - ): List? + since: SincePerRelayMap?, + ): List? - abstract fun id(key: T): String + abstract fun id(key: T): U } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index b02d63f987..047238bb00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -21,11 +21,13 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * This query type creates a new relay subscription for every logged-in @@ -38,24 +40,24 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime * does NOT share EOSEs with other users. Changing the list will not make the * app reuse the EOSE because it assumes the filter is going to be different */ -abstract class PerUserAndFollowListEoseManager( +abstract class PerUserAndFollowListEoseManager( client: NostrClient, allKeys: () -> Set, val invalidateAfterEose: Boolean = false, ) : BaseEoseManager(client, allKeys) { - private val latestEOSEs = EOSEAccount() + private val latestEOSEs = EOSEAccountKey() private val userSubscriptionMap = mutableMapOf() fun since(key: T) = latestEOSEs.since(user(key), list(key)) fun newEose( key: T, - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), list(key), relayUrl, time) + ) = latestEOSEs.newEose(user(key), list(key), relay, time) open fun newSub(key: T): Subscription = - orchestrator.requestNewSubscription { time, relayUrl -> + requestNewSubscription { time, relayUrl -> newEose(key, relayUrl, time) if (invalidateAfterEose) { invalidateFilters() @@ -66,16 +68,17 @@ abstract class PerUserAndFollowListEoseManager( key: User, subId: String, ) { - orchestrator.dismissSubscription(subId) + dismissSubscription(subId) + userSubscriptionMap.remove(key) } fun findOrCreateSubFor(key: T): Subscription { val user = user(key) - var subId = userSubscriptionMap[user] + val subId = userSubscriptionMap[user] return if (subId == null) { newSub(key).also { userSubscriptionMap[user] = it.id } } else { - orchestrator.getSub(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id } + getSubscription(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id } } } @@ -86,24 +89,23 @@ abstract class PerUserAndFollowListEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) - findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null } - + val sub = findOrCreateSubFor(it) + val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) updated.add(user) } - userSubscriptionMap.forEach { - if (it.key !in updated) { - endSub(it.key, it.value) - } + userSubscriptionMap.filter { it.key !in updated }.forEach { + endSub(it.key, it.value) } } abstract fun updateFilter( key: T, - since: Map?, - ): List? + since: SincePerRelayMap?, + ): List? abstract fun user(key: T): User - abstract fun list(key: T): String + abstract fun list(key: T): U } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index 1bbe13acd3..d0d4b4e672 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -22,11 +22,12 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.distinctBy +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * This query type creates a new relay subscription for every distinct @@ -49,12 +50,12 @@ abstract class PerUserEoseManager( fun newEose( key: T, - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), relayUrl, time) + ) = latestEOSEs.newEose(user(key), relay, time) open fun newSub(key: T): Subscription = - orchestrator.requestNewSubscription { time, relayUrl -> + requestNewSubscription { time, relayUrl -> newEose(key, relayUrl, time) if (invalidateAfterEose) { invalidateFilters() @@ -65,16 +66,17 @@ abstract class PerUserEoseManager( key: User, subId: String, ) { - orchestrator.dismissSubscription(subId) + dismissSubscription(subId) + userSubscriptionMap.remove(key) } fun findOrCreateSubFor(key: T): Subscription { val user = user(key) - var subId = userSubscriptionMap[user] + val subId = userSubscriptionMap[user] return if (subId == null) { newSub(key).also { userSubscriptionMap[user] = it.id } } else { - orchestrator.getSub(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id } + getSubscription(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id } } } @@ -85,22 +87,22 @@ abstract class PerUserEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) - findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + + findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) updated.add(user) } - userSubscriptionMap.forEach { - if (it.key !in updated) { - endSub(it.key, it.value) - } + userSubscriptionMap.filter { it.key !in updated }.forEach { + endSub(it.key, it.value) } } abstract fun updateFilter( key: T, - since: Map?, - ): List? + since: SincePerRelayMap?, + ): List? abstract fun user(key: T): User } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt index 10c5eb3b01..4ed8dbbbc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.service.relays.EOSERelayList -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.distinctBy +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * This query type creates only ONE relay subscription. It filters duplicates @@ -49,12 +50,12 @@ abstract class SingleSubEoseManager( fun since() = latestEOSEs.since() open fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(relayUrl, time) + ) = latestEOSEs.newEose(relay, time) val sub = - orchestrator.requestNewSubscription { time, relayUrl -> + requestNewSubscription { time, relayUrl -> newEose(relayUrl, time) if (invalidateAfterEose) { invalidateFilters() @@ -63,14 +64,15 @@ abstract class SingleSubEoseManager( override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } + val newFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null } - sub.typedFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) } abstract fun updateFilter( - key: List, - since: Map?, - ): List? + keys: List, + since: SincePerRelayMap?, + ): List? abstract fun distinct(key: T): Any } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index f897011628..651cc38dd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import kotlin.collections.distinctBy +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay /** * This query type creates only ONE relay subscription and does not @@ -35,7 +35,7 @@ abstract class SingleSubNoEoseCacheEoseManager( val invalidateAfterEose: Boolean = false, ) : BaseEoseManager(client, allKeys) { val sub = - orchestrator.requestNewSubscription { time, relayUrl -> + requestNewSubscription { time, relayUrl -> if (invalidateAfterEose) { invalidateFilters() } @@ -43,11 +43,11 @@ abstract class SingleSubNoEoseCacheEoseManager( override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } - - sub.typedFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) } - abstract fun updateFilter(key: List): List? + abstract fun updateFilter(keys: List): List? abstract fun distinct(key: T): Any } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 921369e0c3..dbd28b2dd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -21,14 +21,16 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.flow.map @Composable fun DisplayNotifyMessages( @@ -42,14 +44,21 @@ fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, ) { - val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle(emptySet()) + val flow = + remember(accountViewModel) { + requests.transientPaymentRequests.map { + it.filter { it.relayUrl in accountViewModel.account.trustedRelays.flow.value } + } + } + + val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet()) openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( title = stringRes( id = R.string.payment_required_title, - RelayUrlFormatter.displayUrl(request.relayUrl), + request.relayUrl.displayUrl(), ), textContent = request.description, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt index 28178a220c..c87d634923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt @@ -40,8 +40,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16dp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 1caa586d57..135b9cc747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.datasources.RelayNotifier +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayNotifier class NotifyCoordinator( client: NostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt index 1d0f35e812..74aefabfac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + data class NotifyRequest( - val relayUrl: String, + val relayUrl: NormalizedRelayUrl, val description: String, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt index a908f54a72..2e7fccc18b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -29,9 +30,9 @@ class NotifyRequestsCache { fun addPaymentRequestIfNew( description: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ) { - addPaymentRequestIfNew(NotifyRequest(description, relayUrl)) + addPaymentRequestIfNew(NotifyRequest(relayUrl, description)) } fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9db47b5aac..4eeb50ed5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterA 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 com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope class RelaySubscriptionsCoordinator( @@ -63,7 +63,7 @@ class RelaySubscriptionsCoordinator( val userFinder = UserFinderFilterAssembler(client) // active when searching or tagging users. - val search = SearchFilterAssembler(cache, client, scope) + val search = SearchFilterAssembler(client, scope, cache) // active depending on the screen. val channel = ChannelFilterAssembler(client) @@ -98,10 +98,6 @@ class RelaySubscriptionsCoordinator( nwc, ) - fun start() = all.forEach { it.start() } - - fun stop() = all.forEach { it.stop() } - fun destroy() = all.forEach { it.destroy() } fun printCounters() = all.forEach { it.printStats() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index a316a6939c..479d3c73ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -23,14 +23,17 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to logged-in accounts. class AccountQueryState( val account: Account, + val feedContentStates: AccountFeedContentStates, val otherAccounts: Set, ) @@ -44,18 +47,15 @@ class AccountFilterAssembler( listOf( AccountMetadataEoseManager(client, ::allKeys), AccountGiftWrapsEoseManager(client, ::allKeys), - AccountNotificationsEoseManager(client, ::allKeys), + AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), + AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt index 44b8cf6994..cae7379f6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt @@ -37,7 +37,7 @@ fun AccountFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel) { - AccountQueryState(accountViewModel.account, accountViewModel.allAccountsSync().toSet()) + AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt index a9529ebcc2..d247179647 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt @@ -37,7 +37,7 @@ fun observeAccountIsHiddenWord( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(account, word) { - account.flowHiddenUsers + account.hiddenUsers.flow .map { word in it.hiddenWords } .distinctUntilChanged() } @@ -53,7 +53,7 @@ fun observeAccountIsHiddenUser( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(account, user) { - account.flowHiddenUsers + account.hiddenUsers.flow .map { it.hiddenUsers.contains(user.pubkeyHex) || it.spammers.contains(user.pubkeyHex) } .distinctUntilChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 26fcb1afb7..cba60b2596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -20,27 +20,65 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class AccountMetadataEoseManager( client: NostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { - override fun user(query: AccountQueryState) = query.account.userProfile() + override fun user(key: AccountQueryState) = key.account.userProfile() + + fun relayFlow(query: AccountQueryState) = query.account.outboxRelays.flow override fun updateFilter( key: AccountQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterAccountInfoAndListsFromKey(user(key).pubkeyHex, since), - filterFollowsAndMutesFromKey(user(key).pubkeyHex, since), - filterDraftsAndReportsFromKey(user(key).pubkeyHex, since), - filterLastPostsFromKey(user(key).pubkeyHex, since), - filterBasicAccountInfoFromKeys(key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), - ).flatten() + since: SincePerRelayMap?, + ): List = + relayFlow(key).value.flatMap { + val since = since?.get(it)?.time + listOf( + filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), + filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), + filterDraftsAndReportsFromKey(it, user(key).pubkeyHex, since), + filterLastPostsFromKey(it, user(key).pubkeyHex, since), + filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), + ).flatten() + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + relayFlow(key).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 0eacefe3ff..40aed99a84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -20,58 +20,91 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -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.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +val AccountInfoAndListsFromKeyKinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + StatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + PrivateOutboxRelayListEvent.KIND, + ) + +val AccountInfoAndListsFromKeyKinds2 = + listOf( + BlockedRelayListEvent.KIND, + TrustedRelayListEvent.KIND, + BroadcastRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ProxyRelayListEvent.KIND, + HashtagListEvent.KIND, + GeohashListEvent.KIND, + ) + +val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) +val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) fun filterAccountInfoAndListsFromKey( - pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - StatusEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - PrivateOutboxRelayListEvent.KIND, - ), + Filter( + kinds = AccountInfoAndListsFromKeyKinds, authors = listOf(pubkey), limit = 20, since = since, ), ), - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(AppSpecificDataEvent.KIND), + Filter( + kinds = AccountInfoAndListsFromKeyKinds2, authors = listOf(pubkey), - tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)), + limit = 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AmethystMetadataKinds, + authors = listOf(pubkey), + tags = AmethystMetadataTagMapFilter, limit = 1, since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index d25ae69745..6348658b42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,42 +20,72 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -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.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +val BasicAccountInfoKinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + ) + +val BasicAccountInfoKinds2 = + listOf( + BlockedRelayListEvent.KIND, + TrustedRelayListEvent.KIND, + BroadcastRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ProxyRelayListEvent.KIND, + HashtagListEvent.KIND, + GeohashListEvent.KIND, + ) fun filterBasicAccountInfoFromKeys( + relay: NormalizedRelayUrl, otherAccounts: List?, - since: Map?, -): List? { - if (otherAccounts == null || otherAccounts.isEmpty()) return null + since: Long?, +): List { + if (otherAccounts == null || otherAccounts.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - ), + Filter( + kinds = BasicAccountInfoKinds, authors = otherAccounts.toList(), - limit = otherAccounts.size * 8, + limit = otherAccounts.size * BasicAccountInfoKinds.size, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = BasicAccountInfoKinds2, + authors = otherAccounts.toList(), + limit = otherAccounts.size * BasicAccountInfoKinds2.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt index b480853b34..c0473e031b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt @@ -20,32 +20,34 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent +val ReportsAndBookmarksFromKeyKinds = + listOf( + DraftEvent.KIND, + ReportEvent.KIND, + BookmarkListEvent.KIND, + ) + fun filterDraftsAndReportsFromKey( + relay: NormalizedRelayUrl, pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - DraftEvent.KIND, - ReportEvent.KIND, - BookmarkListEvent.KIND, - ), + Filter( + kinds = ReportsAndBookmarksFromKeyKinds, authors = listOf(pubkey), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index 78eb060517..d1e8a3ea45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -20,40 +20,42 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent +val FollowAndMutesFromKeyKinds = + listOf( + PeopleListEvent.KIND, + FollowListEvent.KIND, + MuteListEvent.KIND, + BadgeProfilesEvent.KIND, + EmojiPackSelectionEvent.KIND, + EphemeralChatListEvent.KIND, + ChannelListEvent.KIND, + ) + fun filterFollowsAndMutesFromKey( - pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - PeopleListEvent.KIND, - FollowListEvent.KIND, - MuteListEvent.KIND, - BadgeProfilesEvent.KIND, - EmojiPackSelectionEvent.KIND, - EphemeralChatListEvent.KIND, - ChannelListEvent.KIND, - ), + Filter( + kinds = FollowAndMutesFromKeyKinds, authors = listOf(pubkey), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index 6ff9941925..00b0ec1eeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -20,23 +20,23 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterLastPostsFromKey( - pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( authors = listOf(pubkey), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt new file mode 100644 index 0000000000..f9d36d2d32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class AccountNotificationsEoseFromInboxRelaysManager( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(query: AccountQueryState) = query.account.userProfile() + + /** + * Downloads most notifications from the user's own inbox relays. + * But also connects to all the follows relays to check for new notifications that are not in the user's + * own inbox. + */ + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List? = + key.account.notificationRelays.flow.value.flatMap { + filterSummaryNotificationsToPubkey( + relay = it, + pubkey = user(key).pubkeyHex, + since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), + ) + filterNotificationsToPubkey( + relay = it, + pubkey = user(key).pubkeyHex, + since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled(), + ) + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.notificationRelays.flow.sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt similarity index 52% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index c2efb49270..740d93c9cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -18,58 +18,54 @@ * 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.home.datasource.nip65Follows +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -class HomeOutboxUsersEoseManager( +class AccountNotificationsEoseFromRandomRelaysManager( client: NostrClient, - allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(query: AccountQueryState) = query.account.userProfile() + + /** + * Downloads most notifications from the user's own inbox relays. + * But also connects to all the follows relays to check for new notifications that are not in the user's + * own inbox. + */ override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = filterUserMetadataByFollows(key.followRelay(), since) - - override fun user(key: HomeQueryState) = key.account.userProfile() - - override fun list(key: HomeQueryState) = key.listName() - - fun HomeQueryState.listNameFlow() = account.settings.defaultHomeFollowList - - fun HomeQueryState.listName() = listNameFlow().value - - fun HomeQueryState.followRelayFlow() = account.liveHomeListAuthorsPerRelay - - fun HomeQueryState.followRelay() = followRelayFlow().value + key: AccountQueryState, + since: SincePerRelayMap?, + ): List? = + (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { + val since = since?.get(it)?.time ?: TimeUtils.oneDayAgo() + filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) + } val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: HomeQueryState): Subscription { + override fun newSub(key: AccountQueryState): Subscription { val user = user(key) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( - key.scope.launch(Dispatchers.Default) { - key.listNameFlow().collectLatest { - invalidateFilters() - } - }, - key.scope.launch(Dispatchers.Default) { - key.followRelayFlow().sample(5000).collectLatest { + key.account.scope.launch(Dispatchers.Default) { + // no need to hurry here. we can wait the app stabilize + key.account.followsPerRelay.debounce(5000).collectLatest { invalidateFilters() } }, @@ -82,7 +78,7 @@ class HomeOutboxUsersEoseManager( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt deleted file mode 100644 index 11d28a4377..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications - -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime - -class AccountNotificationsEoseManager( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(query: AccountQueryState) = query.account.userProfile() - - override fun updateFilter( - key: AccountQueryState, - since: Map?, - ): List? = filterNotificationsToPubkey(user(key).pubkeyHex, since) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 6f53eb51b3..4f75d15218 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -47,62 +47,84 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -fun filterNotificationsToPubkey( +val SummaryKinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LnZapEvent.KIND, + ) + +val NotificationsPerKeyKinds = + listOf( + ReportEvent.KIND, + LnZapPaymentResponseEvent.KIND, + ChannelMessageEvent.KIND, + EphemeralChatEvent.KIND, + BadgeAwardEvent.KIND, + PollNoteEvent.KIND, + PublicMessageEvent.KIND, + ) + +val NotificationsPerKeyKinds2 = + listOf( + GitReplyEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + HighlightEvent.KIND, + CommentEvent.KIND, + CalendarDateSlotEvent.KIND, + CalendarTimeSlotEvent.KIND, + CalendarRSVPEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + ) + +fun filterSummaryNotificationsToPubkey( + relay: NormalizedRelayUrl, pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - LnZapPaymentResponseEvent.KIND, - ChannelMessageEvent.KIND, - EphemeralChatEvent.KIND, - BadgeAwardEvent.KIND, - ), + Filter( + kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 2000, since = since, ), ), - TypedFilter( - types = COMMON_FEED_TYPES, + ) +} + +fun filterNotificationsToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - GitReplyEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - HighlightEvent.KIND, - CommentEvent.KIND, - CalendarDateSlotEvent.KIND, - CalendarTimeSlotEvent.KIND, - CalendarRSVPEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - ), + Filter( + kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), - limit = 400, + limit = 50, since = since, ), ), - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(PollNoteEvent.KIND), + Filter( + kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 100, since = since, @@ -110,3 +132,44 @@ fun filterNotificationsToPubkey( ), ) } + +fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SummaryKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = 10, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = 5, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds2, + tags = mapOf("p" to listOf(pubkey)), + limit = 5, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 1965272820..79928cd57d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -20,20 +20,60 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class AccountGiftWrapsEoseManager( client: NostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { - override fun user(query: AccountQueryState) = query.account.userProfile() + override fun user(key: AccountQueryState) = key.account.userProfile() override fun updateFilter( key: AccountQueryState, - since: Map?, - ): List? = filterGiftWrapsToPubkey(user(key).pubkeyHex, since) + since: SincePerRelayMap?, + ): List = + key.account.dmRelays.flow.value.flatMap { relay -> + filterGiftWrapsToPubkey( + relay = relay, + pubkey = user(key).pubkeyHex, + since = since?.get(relay)?.time, + ) + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.dmRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt index 2ea5f5057b..a9b0765a22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt @@ -20,31 +20,28 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils fun filterGiftWrapsToPubkey( + relay: NormalizedRelayUrl, pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(GiftWrapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), - since = - since?.mapValues { - EOSETime(it.value.time - TimeUtils.twoDays()) - }, + since = since?.minus(TimeUtils.twoDays()), ), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 348d101fd7..797d2cccec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ChannelFinderQueryState( @@ -44,10 +44,6 @@ class ChannelFinderFilterAssemblyGroup( ChannelMetadataAndLiveActivityWatcherSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt index cfd22c6114..7e83e71498 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt @@ -26,8 +26,9 @@ import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.ChannelState -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import kotlinx.collections.immutable.ImmutableList @@ -73,10 +74,12 @@ fun observeChannelNoteAuthors( .toSet() .toImmutableList() }.onStart { - baseChannel.notes - .mapNotNull { key, value -> value.author } - .toSet() - .toImmutableList() + emit( + baseChannel.notes + .mapNotNull { key, value -> value.author } + .toSet() + .toImmutableList(), + ) }.distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -87,7 +90,7 @@ fun observeChannelNoteAuthors( @OptIn(ExperimentalCoroutinesApi::class) @Composable fun observeChannelPicture( - baseChannel: Channel, + baseChannel: PublicChatChannel, accountViewModel: AccountViewModel, ): State { // Subscribe in the relay for changes in the metadata of this user. @@ -99,7 +102,7 @@ fun observeChannelPicture( baseChannel .flow() .metadata.stateFlow - .mapLatest { it.channel.profilePicture() } + .mapLatest { (it.channel as? PublicChatChannel)?.profilePicture() } .distinctUntilChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt index efd139f458..541605b070 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.mapOfSet /** * This assembler observes modifications to the LiveActivity root events @@ -43,18 +44,37 @@ class ChannelMetadataAndLiveActivityWatcherSubAssembler( ) : SingleSubEoseManager(client, allKeys) { override fun updateFilter( keys: List, - since: Map?, - ): List? = - keys - .mapNotNull { key -> - if (key.channel is PublicChatChannel) { - filterChannelMetadataUpdatesById(key.channel, since) - } else if (key.channel is LiveActivitiesChannel) { - filterLiveStreamUpdatesByAddress(key.channel, since) - } else { - null + since: SincePerRelayMap?, + ): List { + val perRelayPublicChannelFilter = + mapOfSet { + keys.forEach { key -> + if (key.channel is PublicChatChannel) { + key.channel.relays().forEach { + add(it, key.channel) + } + } } - }.flatten() + } - override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex + val perRelayLiveActivityFilter = + mapOfSet { + keys.forEach { key -> + if (key.channel is LiveActivitiesChannel) { + key.channel.relays().forEach { + add(it, key.channel) + } + } + } + } + + return perRelayPublicChannelFilter.flatMap { (relay, channels) -> + filterChannelMetadataUpdatesById(relay, channels.toList(), since?.get(relay)?.time) + } + + perRelayLiveActivityFilter.flatMap { (relay, channels) -> + filterLiveStreamUpdatesByAddress(relay, channels.toList(), since?.get(relay)?.time) + } + } + + override fun distinct(key: ChannelFinderQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index 399338b81c..5931a339c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * This assembler observes loads missing public chats when needed. @@ -38,7 +38,7 @@ class ChannelLoaderSubAssembler( client: NostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? = filterMissingChannelsById(keys) + override fun updateFilter(keys: List): List? = filterMissingChannelsById(keys) - override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex + override fun distinct(key: ChannelFinderQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt index 2b7519be9e..f1a15eea27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt @@ -20,29 +20,32 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelMetadataWatcherSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ChannelFinderQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List = if (key.channel is PublicChatChannel) { - filterChannelMetadataUpdatesById(key.channel, since) + key.channel.relays().flatMap { + filterChannelMetadataUpdatesById(it, listOf(key.channel), since?.get(it)?.time) + } } else { - null + emptyList() } /** * Only one key per channel. */ - override fun id(key: ChannelFinderQueryState) = key.channel.idHex + override fun id(key: ChannelFinderQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index 8613411913..8841556149 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -20,33 +20,43 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterMissingChannelsById(keys: List): List? { - val firstTimers = - keys.mapNotNullTo(mutableSetOf()) { - if (it.channel is PublicChatChannel && it.channel.event == null) { - it.channel.idHex - } else { - null +val filterMissingPublicChannelsByIdKinds = listOf(ChannelCreateEvent.KIND) + +fun filterMissingChannelsById(keys: List): List { + val relayPerChannel = + mapOfSet { + keys.forEach { key -> + if (key.channel is PublicChatChannel && key.channel.event == null) { + key.channel.relays().forEach { + add(it, key.channel.idHex) + } + } else { + null + } } } - if (firstTimers.isEmpty()) return null + if (relayPerChannel.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelCreateEvent.KIND), - ids = firstTimers.toList(), - ), - ), - ) + return relayPerChannel.mapNotNull { + if (it.value.isEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = filterMissingPublicChannelsByIdKinds, + ids = it.value.sorted(), + ), + ) + } else { + null + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt index 518f44b994..86c876a9ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt @@ -20,24 +20,26 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +val channelMetadataKinds = listOf(ChannelMetadataEvent.KIND) + fun filterChannelMetadataUpdatesById( - channel: PublicChatChannel, - since: Map?, -): List = + relay: NormalizedRelayUrl, + channels: List, + since: Long?, +): List = listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to listOf(channel.idHex)), + Filter( + kinds = channelMetadataKinds, + tags = mapOf("e" to channels.map { it.idHex }), since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt index c0a77a9a35..07f98233bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt @@ -20,25 +20,26 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent fun filterLiveStreamUpdatesByAddress( - channel: LiveActivitiesChannel, - since: Map?, -): List { - val stream = channel.address() + relay: NormalizedRelayUrl, + channels: List, + since: Long?, +): List { + // this runs a cross product of all ds and for pubkeys, but we are assuming they are quite unique. return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(stream.kind), - tags = mapOf("d" to listOf(stream.dTag)), - authors = listOf(stream.pubKeyHex), + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + tags = mapOf("d" to channels.map { it.address.dTag }), + authors = channels.map { it.address.pubKeyHex }, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt index 7a7a677ef8..739c118b60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * This assembler observes modifications to the LiveActivity root events @@ -34,19 +35,21 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime class LiveActivityWatcherSubAssembly( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ChannelFinderQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List = if (key.channel is LiveActivitiesChannel) { - filterLiveStreamUpdatesByAddress(key.channel, since) + key.channel.relays().flatMap { + filterLiveStreamUpdatesByAddress(it, listOf(key.channel), since?.get(it)?.time) + } } else { - null + emptyList() } /** * Only one key per channel. */ - override fun id(key: ChannelFinderQueryState) = key.channel.idHex + override fun id(key: ChannelFinderQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 2176a1c349..c9a9685cdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -20,15 +20,17 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class EventFinderQueryState( val note: Note, + val account: Account, ) class EventFinderFilterAssembler( @@ -40,10 +42,6 @@ class EventFinderFilterAssembler( EventWatcherSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt index 938c514ead..d0ffe095e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -30,18 +31,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel fun EventFinderFilterAssemblerSubscription( note: Note, accountViewModel: AccountViewModel, -) = EventFinderFilterAssemblerSubscription(note, accountViewModel.dataSources().eventFinder) +) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun EventFinderFilterAssemblerSubscription( note: Note, + account: Account, dataSource: EventFinderFilterAssembler, ) { // different screens get different states // even if they are tracking the same tag. val state = - remember(note) { - EventFinderQueryState(note) + remember(note, account) { + EventFinderQueryState(note, account) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index ba74756058..960018f896 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -176,9 +176,9 @@ fun observeNoteReplyCount( remember(note) { note .flow() - .reactions.stateFlow + .replies.stateFlow + .sample(200) .mapLatest { it.note.replies.size } - .sample(1000) .distinctUntilChanged() } @@ -215,8 +215,8 @@ fun observeNoteReactionCount( note .flow() .reactions.stateFlow + .sample(200) .mapLatest { it.note.countReactions() } - .sample(1000) .distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -294,7 +294,7 @@ fun observeNoteRepostCount( note .flow() .boosts.stateFlow - .sample(1000) + .sample(200) .mapLatest { note.boosts.size } .distinctUntilChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt index fd0fb282c6..5beab5ac6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt @@ -21,56 +21,104 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterMissingAddressables(keys: List): List? { - val missingAddressables = mutableSetOf
() +fun potentialRelaysToFindAddress(note: AddressableNote): Set { + val set = mutableSetOf() - keys.forEach { - if (it.note is AddressableNote && it.note.event == null) { - missingAddressables.add(it.note.address()) + set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex)) + + LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + + note.replyTo?.map { parentNote -> + set.addAll(parentNote.relays) + + LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + + parentNote.author?.inboxRelays()?.let { set.addAll(it) } + } + + note.replies.map { childNote -> + set.addAll(childNote.relays) + + LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + note.reactions.map { reactionType -> + reactionType.value.forEach { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + } + + note.boosts.map { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + return set +} + +fun filterMissingAddressables(keys: List): List? { + val addressesPerRelay = + mapOfSet { + keys.forEach { key -> + val default = key.account.followPlusAllMine.flow.value + if (key.note is AddressableNote && key.note.event == null) { + potentialRelaysToFindAddress(key.note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, key.note.address) + } + } + + // loads threading that is event-based + key.note.replyTo?.forEach { note -> + if (note is AddressableNote && note.event == null) { + potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.address) + } + } + } + } } - // loads threading that is event-based - it.note.replyTo?.forEach { - if (it is AddressableNote && it.event == null) { - missingAddressables.add(it.address()) + return filterMissingAddressables(addressesPerRelay) +} + +fun filterMissingAddressables(missingAddressables: Map>): List { + if (missingAddressables.isEmpty()) return emptyList() + + return missingAddressables.flatMap { relayEntry -> + relayEntry.value.map { address -> + if (address.kind < 25000 && address.dTag.isBlank()) { + RelayBasedFilter( + relay = relayEntry.key, + filter = + Filter( + kinds = listOf(address.kind), + authors = listOf(address.pubKeyHex), + limit = 1, + ), + ) + } else { + RelayBasedFilter( + relay = relayEntry.key, + filter = + Filter( + kinds = listOf(address.kind), + tags = mapOf("d" to listOf(address.dTag)), + authors = listOf(address.pubKeyHex), + limit = 1, + ), + ) } } } - - return filterMissingAddressables(missingAddressables) -} - -fun filterMissingAddressables(missingAddressables: Set
): List? { - if (missingAddressables.isEmpty()) return null - - return missingAddressables.map { aTag -> - if (aTag.kind < 25000 && aTag.dTag.isBlank()) { - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - authors = listOf(aTag.pubKeyHex), - limit = 1, - ), - ) - } else { - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - limit = 1, - ), - ) - } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index 9fb917c1d5..b8f6e1745f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -21,38 +21,89 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterMissingEvents(keys: List): List? { - val missingEvents = mutableSetOf() +fun potentialRelaysToFindEvent(note: Note): Set { + val set = mutableSetOf() - keys.forEach { - if (it.note !is AddressableNote && it.note.event == null) { - missingEvents.add(it.note.idHex) - } + set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex)) - // loads threading that is event-based - it.note.replyTo?.forEach { - if (it !is AddressableNote && it.event == null) { - missingEvents.add(it.idHex) - } + LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + + note.replyTo?.map { parentNote -> + set.addAll(parentNote.relays) + + LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + + parentNote.author?.inboxRelays()?.let { set.addAll(it) } + } + + note.replies.map { childNote -> + set.addAll(childNote.relays) + + LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + note.reactions.map { reactionType -> + reactionType.value.forEach { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } } } - return filterMissingEvents(missingEvents) + note.boosts.map { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + return set } -fun filterMissingEvents(missingEventIds: Set): List? { - if (missingEventIds.isEmpty()) return null +fun filterMissingEvents(keys: List): List? { + val eventsPerRelay = + mapOfSet { + keys.forEach { key -> + val default = key.account.followPlusAllMine.flow.value - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = SincePerRelayFilter(ids = missingEventIds.sorted()), - ), - ) + if (key.note !is AddressableNote && key.note.event == null) { + potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, key.note.idHex) + } + } + + // loads threading that is event-based + key.note.replyTo?.forEach { note -> + if (note !is AddressableNote && note.event == null) { + potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.idHex) + } + } + } + } + } + + return filterMissingEvents(eventsPerRelay) +} + +fun filterMissingEvents(missingEventIds: Map>): List { + if (missingEventIds.isEmpty()) return emptyList() + + return missingEventIds.mapNotNull { + if (it.value.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = Filter(ids = it.value.sorted()), + ) + } else { + null + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt index 2a64971696..715a6f48eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient class NoteEventLoaderSubAssembler( client: NostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index a9ff3eb0e4..36cff0b093 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -25,9 +25,11 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class EventWatcherSubAssembler( client: NostrClient, @@ -37,24 +39,24 @@ class EventWatcherSubAssembler( var latestEOSEs: EOSEAccountFast = EOSEAccountFast(10000) override fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, ) { lastNotesOnFilter.forEach { - latestEOSEs.newEose(it, relayUrl, time) + latestEOSEs.newEose(it, relay, time) } - super.newEose(relayUrl, time) + super.newEose(relay, time) } override fun updateFilter( - keys: List, - since: Map?, - ): List? { - if (keys.isEmpty()) { + key: List, + since: SincePerRelayMap?, + ): List? { + if (key.isEmpty()) { return null } - lastNotesOnFilter = keys.map { it.note } + lastNotesOnFilter = key.map { it.note } return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) .map { group -> @@ -65,7 +67,6 @@ class EventWatcherSubAssembler( listOfNotNull( filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)), filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)), - filterQuotesToNotes(group, findMinimumEOSEs(group, latestEOSEs)), ).flatten() } else { emptyList() @@ -79,8 +80,10 @@ class EventWatcherSubAssembler( notes: Iterable, eoseCache: EOSEAccountFast, ): Collection> = - notes.groupBy { eoseCache.since(it)?.keys?.hashCode() } - .values.map { + notes + .groupBy { eoseCache.since(it)?.keys?.hashCode() } + .values + .map { // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again it.sortedBy { it.idHex } } @@ -88,16 +91,16 @@ class EventWatcherSubAssembler( fun findMinimumEOSEs( notes: List, eoseCache: EOSEAccountFast, - ): Map { - val minLatestEOSEs = mutableMapOf() + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() notes.forEach { note -> eoseCache.since(note)?.forEach { val minEose = minLatestEOSEs[it.key] if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 244d613c20..b010546033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -21,11 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -35,51 +34,95 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val RepliesAndReactionsToAddressesKinds1 = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + ) + +val PostsAndChatMessagesToAddresses = + listOf( + CommunityPostApprovalEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ) + +val DeletionKindList = + listOf( + DeletionEvent.KIND, + ) + +val TextNoteKindList = listOf(TextNoteEvent.KIND) fun filterRepliesAndReactionsToAddresses( keys: List, - since: Map?, -): List? { + since: SincePerRelayMap?, +): List? { if (keys.isEmpty()) return null - val addresses = keys.mapTo(mutableSetOf()) { it.address().toValue() }.sorted() + val notesPerRelay = + mapOfSet { + keys.forEach { + it.relayUrlsForReactions().forEach { relay -> + add(relay, it.address().toValue()) + } + } + } - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ), - tags = mapOf("a" to addresses), - since = since, - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - DeletionEvent.KIND, - ), - tags = mapOf("a" to addresses), - since = since, - // Max amount of "replies" to download on a specific event. - limit = 10, - ), - ), - ) + return notesPerRelay.flatMap { + val since = since?.get(it.key)?.time + val sortedList = it.value.sorted() + val relay = it.key + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsToAddressesKinds1, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PostsAndChatMessagesToAddresses, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = DeletionKindList, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 10, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = TextNoteKindList, + tags = mapOf("q" to sortedList), + since = since, + limit = 1000, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index b738ae4e0d..a6c6d05949 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -21,12 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -39,54 +38,85 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val RepliesAndReactionsKinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + OtsEvent.KIND, + TextNoteModificationEvent.KIND, + GitReplyEvent.KIND, + ) + +val RepliesAndReactionsKinds2 = + listOf( + DeletionEvent.KIND, + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + TorrentCommentEvent.KIND, + ) fun filterRepliesAndReactionsToNotes( events: List, - since: Map?, -): List? { + since: SincePerRelayMap?, +): List? { if (events.isEmpty()) return null - val eventIds = events.mapTo(mutableSetOf()) { it.idHex }.sorted() + val perRelayEventIds = + mapOfSet { + events.forEach { note -> + note.relayUrlsForReactions().forEach { relay -> + add(relay, note.idHex) + } + } + } - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - OtsEvent.KIND, - TextNoteModificationEvent.KIND, - GitReplyEvent.KIND, + return perRelayEventIds.flatMap { + val since = since?.get(it.key)?.time + val sortedList = it.value.sorted() + val relay = it.key + if (sortedList.isNotEmpty()) { + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsKinds, + tags = mapOf("e" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 1000, ), - tags = mapOf("e" to eventIds), - since = since, - // Max amount of "replies" to download on a specific event. - limit = 1000, ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - DeletionEvent.KIND, - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, - TorrentCommentEvent.KIND, + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsKinds2, + tags = mapOf("e" to sortedList), + since = since, + limit = 100, ), - tags = mapOf("e" to eventIds), - since = since, - limit = 100, ), - ), - ) + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("q" to sortedList), + since = since, + limit = 1000, + ), + ), + ) + } else { + emptyList() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt index 6a21e0517d..140ca7ca05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt @@ -20,31 +20,21 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent fun filterNWCPaymentsFromRequests( serviceKeys: Set, - paymentRequets: Set, + paymentRequests: Set, fromUsers: Set, - since: Map?, -): List = - listOf( - TypedFilter( - types = setOf(FeedType.WALLET_CONNECT), - filter = - SincePerRelayFilter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), - authors = serviceKeys.toList(), - tags = - mapOf( - "e" to paymentRequets.toList(), - "p" to fromUsers.toList(), - ), - ), - ), +): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = serviceKeys.sorted(), + tags = + mapOf( + "e" to paymentRequests.sorted(), + "p" to fromUsers.sorted(), + ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..fbb130e337 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun NWCFinderFilterAssemblerSubscription( + note: Note, + accountViewModel: AccountViewModel, +) = NWCFinderFilterAssemblerSubscription( + note, + accountViewModel.dataSources().nwc, +) + +@Composable +fun NWCFinderFilterAssemblerSubscription( + note: Note, + dataSource: NWCPaymentFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val states = + remember(note) { + val zapPaymentRequestNote = note + (zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent -> + noteEvent.walletServicePubKey()?.let { serviceId -> + zapPaymentRequestNote.relays.map { + NWCPaymentQueryState( + fromServiceHex = serviceId, + toUserHex = noteEvent.pubKey, + replyingToHex = noteEvent.id, + relay = it, + ) + } + } + } + } + + states?.forEach { + KeyDataSourceSubscription(it, dataSource) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index 361036420f..71ca89d703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -21,13 +21,16 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag class NWCPaymentQueryState( - val fromServiceHex: String, - val toUserHex: String, - val replyingToHex: String, + val fromServiceHex: HexKey, + val toUserHex: HexKey, + val replyingToHex: HexKey, + val relay: NormalizedRelayUrl, ) class NWCPaymentFilterAssembler( @@ -38,10 +41,6 @@ class NWCPaymentFilterAssembler( NWCPaymentWatcherSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt index 24bed7d9bd..98e15761e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -20,26 +20,29 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class NWCPaymentWatcherSubAssembler( client: NostrClient, allKeys: () -> Set, -) : SingleSubEoseManager(client, allKeys) { - override fun updateFilter( - keys: List, - since: Map?, - ): List? { - val fromAuthors = keys.mapTo(mutableSetOf()) { it.fromServiceHex } - val replyingToPayments = keys.mapTo(mutableSetOf()) { it.replyingToHex } - val aboutUsers = keys.mapTo(mutableSetOf()) { it.toUserHex } +) : SingleSubNoEoseCacheEoseManager(client, allKeys) { + override fun updateFilter(keys: List): List? { + if (keys.isEmpty()) return null - if (fromAuthors.isEmpty()) return null + return keys.groupBy { it.relay }.map { relayGroup -> + val fromAuthors = relayGroup.value.mapTo(mutableSetOf()) { it.fromServiceHex } + val replyingToPayments = relayGroup.value.mapTo(mutableSetOf()) { it.replyingToHex } + val aboutUsers = relayGroup.value.mapTo(mutableSetOf()) { it.toUserHex } - return filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers, since) + if (fromAuthors.isEmpty() || replyingToPayments.isEmpty()) return null + + RelayBasedFilter( + relay = relayGroup.key, + filter = filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers), + ) + } } override fun distinct(key: NWCPaymentQueryState) = key.replyingToHex diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 115c48be23..957107df0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -20,15 +20,18 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserLoaderSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class UserFinderQueryState( val user: User, + val account: Account, ) class UserFinderFilterAssembler( @@ -38,12 +41,9 @@ class UserFinderFilterAssembler( listOf( UserLoaderSubAssembler(client, ::allKeys), UserWatcherSubAssembler(client, ::allKeys), + UserReportsSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index a171e007e4..7e1450331e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -20,28 +20,36 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun UserFinderFilterAssemblerSubscription( user: User, accountViewModel: AccountViewModel, -) = UserFinderFilterAssemblerSubscription(user, accountViewModel.dataSources().userFinder) +) = UserFinderFilterAssemblerSubscription( + user, + accountViewModel.account, + accountViewModel.dataSources().userFinder, +) @Composable fun UserFinderFilterAssemblerSubscription( user: User, + forAccount: Account, dataSource: UserFinderFilterAssembler, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(user) { - UserFinderQueryState(user) + UserFinderQueryState(user, forAccount) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index cd71650b46..6b354e41da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -27,15 +27,17 @@ import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -45,6 +47,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.sample import java.math.BigDecimal @@ -247,7 +250,7 @@ fun observeUserFollowCount( user .flow() .followers.stateFlow - .sample(1000) + .sample(200) .mapLatest { userState -> userState.user.transientFollowCount() ?: 0 }.distinctUntilChanged() @@ -257,44 +260,81 @@ fun observeUserFollowCount( return flow.collectAsStateWithLifecycle(0) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserTagFollowCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + accountViewModel + .hashtagFollows(user) + .flow() + .metadata.stateFlow + .sample(1000) + .mapLatest { noteState -> + (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0 + }.onStart { + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(0) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserTagFollows( user: User, accountViewModel: AccountViewModel, -): State { +): State> { // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(user) { - user + accountViewModel + .hashtagFollows(user) .flow() - .follows.stateFlow - .sample(1000) - .mapLatest { userState -> - userState.user.latestContactList?.countFollowTags() ?: 0 + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList() + }.onStart { + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList()) }.distinctUntilChanged() .flowOn(Dispatchers.Default) } - return flow.collectAsStateWithLifecycle(0) + return flow.collectAsStateWithLifecycle(emptyList()) } @Composable fun observeUserBookmarks( user: User, accountViewModel: AccountViewModel, -): State { +): State { // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - return user - .flow() - .bookmarks.stateFlow - .collectAsStateWithLifecycle() + val flow = + remember(user) { + accountViewModel + .bookmarks(user) + .flow() + .metadata.stateFlow + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle() } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -309,12 +349,13 @@ fun observeUserBookmarkCount( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(user) { - user + accountViewModel + .bookmarks(user) .flow() - .followers.stateFlow - .sample(1000) - .mapLatest { userState -> - userState.user.latestBookmarkList?.countBookmarks() ?: 0 + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? BookmarkListEvent)?.countBookmarks() ?: 0 }.distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -352,7 +393,7 @@ fun observeUserFollowerCount( user .flow() .followers.stateFlow - .sample(1000) + .sample(200) .mapLatest { userState -> userState.user.transientFollowerCount() }.distinctUntilChanged() @@ -455,7 +496,7 @@ fun observeUserIsFollowingChannel( remember(account) { account .publicChatList - .livePublicChatEventIdSet + .flowSet .mapLatest { followingChannels -> channel.idHex in followingChannels }.distinctUntilChanged() @@ -463,7 +504,7 @@ fun observeUserIsFollowingChannel( } @SuppressLint("StateFlowValueCalledInComposition") - return flow.collectAsStateWithLifecycle(channel.idHex in account.publicChatList.livePublicChatEventIdSet.value) + return flow.collectAsStateWithLifecycle(channel.idHex in account.publicChatList.flowSet.value) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -603,7 +644,7 @@ fun observeUserStatuses( @Composable fun observeUserRelayIntoList( user: User, - relayUrl: String, + relayUrl: NormalizedRelayUrl, accountViewModel: AccountViewModel, ): State { // Subscribe in the relay for changes in the metadata of this user. @@ -628,36 +669,9 @@ fun observeUserRelayIntoList( return flow.collectAsStateWithLifecycle(false) } -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -@Composable -fun observeUserRoomSubject( - user: User, - room: ChatroomKey, - accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - - // Subscribe in the LocalCache for changes that arrive in the device - val flow = - remember(user) { - user - .flow() - .messages - .stateFlow - .sample(1000) - .mapLatest { userState -> - userState.user.privateChatrooms[room]?.subject - }.distinctUntilChanged() - .flowOn(Dispatchers.Default) - } - - return flow.collectAsStateWithLifecycle(user.privateChatrooms[room]?.subject) -} - data class RelayUsage( - val relays: List = emptyList(), - val userRelayList: List = emptyList(), + val relays: List = emptyList(), + val userRelayList: List = emptyList(), ) @OptIn(FlowPreview::class) @@ -677,7 +691,7 @@ fun observeUserRelaysUsing( val currentUserRelays = relayInfo.user.latestContactList ?.relays() - ?.map { RelayUrlFormatter.normalize(it.key) } ?: emptyList() + ?.map { it.key } ?: emptyList() RelayUsage(userRelaysBeingUsed, currentUserRelays) }.sample(1000) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt index 5e603897a4..d147ef3fff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -20,25 +20,60 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterNewUserMetadataForKey(authors: Set): List = +val MetadataAndRelayListKinds = listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ), - authors = authors.toList(), - ), - ), + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, ) + +fun filterFindUserMetadataForKey( + author: HexKey, + defaultRelays: Set, +): List = + LocalCache.checkGetOrCreateUser(author)?.let { + filterFindUserMetadataForKey(setOf(it), defaultRelays) + } ?: emptyList() + +fun filterFindUserMetadataForKey( + authors: Set, + defaultRelays: Set, +): List { + val perRelayKeys = + mapOfSet { + authors.forEach { key -> + val relays = + key.authorRelayList()?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(key.pubkeyHex).ifEmpty { null } + ?: (key.relaysBeingUsed.keys + defaultRelays).toList() + + relays.forEach { + add(it, key.pubkeyHex) + } + } + } + + return perRelayKeys.mapNotNull { + if (it.value.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = MetadataAndRelayListKinds, + authors = it.value.sorted(), + ), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt index 18f612c875..83c8181260 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt @@ -20,21 +20,47 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class UserLoaderSubAssembler( client: NostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? { - val firstTimers = keys.filter { it.user.latestMetadata == null }.mapTo(mutableSetOf()) { it.user.pubkeyHex } + override fun updateFilter(keys: List): List? { + val firstTimers = mutableSetOf() + + keys.forEach { + if (it.user.latestMetadata == null) { + firstTimers.add(it.user) + } else { + null + } + } + + val defaultRelays = mutableSetOf() + + keys.mapTo(mutableSetOf()) { it.account }.forEach { + defaultRelays.addAll(it.followPlusAllMine.flow.value) + + it.kind3FollowList.flow.value.authors.forEach { + val user = LocalCache.getOrCreateUser(it) + if (user.latestMetadata == null) { + firstTimers.add(user) + } else { + null + } + } + } if (firstTimers.isEmpty()) return null - return filterNewUserMetadataForKey(firstTimers) + return filterFindUserMetadataForKey(firstTimers, defaultRelays) } override fun distinct(key: UserFinderQueryState) = key.user diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index 6984ac7f94..d21cce173b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -20,25 +20,36 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.ReportEvent -fun filterReportsToKeys( - authors: Set, - since: Map?, -): List = - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ReportEvent.KIND), - tags = mapOf("p" to authors.toList()), - since = since, - ), - ), - ) +val ReportKindList = listOf(ReportEvent.KIND) + +fun filterReportsToKeysFromTrusted( + targets: Set, + trustedAccounts: Map>, + since: SincePerRelayMap?, +): List { + if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList() + val sortedTargets = mapOf("p" to targets.sorted()) + return trustedAccounts.mapNotNull { + if (it.value.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = ReportKindList, + authors = it.value.sorted(), + tags = sortedTargets, + since = since?.get(it.key)?.time, + ), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 69e2bc3658..ebcd0f627a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -20,36 +20,51 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +val UserMetadataForKeyKinds = + listOf( + MetadataEvent.KIND, + StatusEvent.KIND, + RelationshipStatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + ) + fun filterUserMetadataForKey( authors: Set, - since: Map?, -): List = - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + since: SincePerRelayMap?, +): List { + val relays = + authors + .map { + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + }.flatten() + .toSet() + + return relays.map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - StatusEvent.KIND, - RelationshipStatusEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - ), + Filter( + kinds = UserMetadataForKeyKinds, authors = authors.toList(), - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt new file mode 100644 index 0000000000..d90135544f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +class UserReportsSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + var lastUsersOnFilter: Set = emptySet() + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) + + override fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) { + lastUsersOnFilter.forEach { + latestEOSEs.newEose(it, relay, time) + } + super.newEose(relay, time) + } + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + if (keys.isEmpty()) return null + + lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user } + + if (lastUsersOnFilter.isEmpty()) return null + + val accounts = keys.mapTo(mutableSetOf()) { it.account } + + val trustedAccounts = + mapOfSet { + accounts.map { it.followsPerRelay.value }.forEach { + add(it) + } + } + + return groupByRelayPresence(lastUsersOnFilter, latestEOSEs, trustedAccounts.keys) + .map { group -> + val groupIds = group.map { it.pubkeyHex }.toSet() + if (groupIds.isNotEmpty()) { + val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) + filterReportsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs) + } else { + emptyList() + } + }.flatten() + } + + fun groupByRelayPresence( + users: Iterable, + eoseCache: EOSEAccountFast, + inRelays: Set, + ): Collection> = + users + .groupBy { + eoseCache + .since(it) + ?.keys + ?.intersect(inRelays) + ?.hashCode() + }.values + .map { + // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again + it.sortedBy { it.pubkeyHex } + } + + fun findMinimumEOSEsForUsers( + users: List, + eoseCache: EOSEAccountFast, + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() + + users.forEach { + eoseCache.since(it)?.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) + } + } + } + + return minLatestEOSEs + } + + override fun distinct(key: UserFinderQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 5736d67af4..1c9cfb4779 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -24,34 +24,44 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class UserWatcherSubAssembler( client: NostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { var lastUsersOnFilter: Set = emptySet() + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) override fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, ) { lastUsersOnFilter.forEach { - latestEOSEs.newEose(it, relayUrl, time) + latestEOSEs.newEose(it, relay, time) } - super.newEose(relayUrl, time) + super.newEose(relay, time) } override fun updateFilter( keys: List, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { if (keys.isEmpty()) return null - lastUsersOnFilter = keys.filter { it.user.latestMetadata != null }.map { it.user }.toSet() + lastUsersOnFilter = + keys.mapNotNullTo(mutableSetOf()) { + if (it.user.latestMetadata != null) it.user else null + } if (lastUsersOnFilter.isEmpty()) return null @@ -60,7 +70,7 @@ class UserWatcherSubAssembler( val groupIds = group.map { it.pubkeyHex }.toSet() if (groupIds.isNotEmpty()) { val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) - filterUserMetadataForKey(groupIds, minEOSEs) + filterReportsToKeys(groupIds, minEOSEs) + filterUserMetadataForKey(groupIds, minEOSEs) } else { emptyList() } @@ -82,16 +92,16 @@ class UserWatcherSubAssembler( fun findMinimumEOSEsForUsers( users: List, eoseCache: EOSEAccountFast, - ): Map { - val minLatestEOSEs = mutableMapOf() + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() users.forEach { eoseCache.since(it)?.forEach { val minEose = minLatestEOSEs[it.key] if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt index 8d9f40ea7a..754332de8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,24 +34,21 @@ import kotlinx.coroutines.flow.MutableStateFlow @Stable class SearchQueryState( val searchQuery: MutableStateFlow, + val account: Account, ) : MutableQueryState { override fun flow(): Flow = searchQuery } class SearchFilterAssembler( - val cache: LocalCache, client: NostrClient, scope: CoroutineScope, + val cache: LocalCache, ) : MutableComposeSubscriptionManager(scope) { val group = listOf( SearchWatcherSubAssembler(cache, client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt index 3f1155159c..7b4ee76089 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -20,43 +20,28 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterByAddress(address: NAddress) = - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(address.author), - limit = 1, - ), - ), - if (address.kind < 25000 && address.dTag.isBlank()) { - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(address.kind), - authors = listOf(address.author), - limit = 5, - ), - ) - } else { - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(address.kind), - tags = mapOf("d" to listOf(address.dTag)), - authors = listOf(address.author), - limit = 5, - ), - ) - }, - ) +fun filterByAddress( + address: NAddress, + default: Set, +): List { + val note = LocalCache.getOrCreateAddressableNote(address.address()) + + val list = + mapOfSet { + if (note.event == null) { + potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.address) + } + } + } + + return filterMissingAddressables(list) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index 8f84a487c8..c3b2efd28a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -20,21 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.filterFindUserMetadataForKey import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -fun filterByAuthor(pubKey: HexKey) = - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(pubKey), - limit = 1, - ), - ), - ) +fun filterByAuthor( + pubKey: HexKey, + defaultRelays: Set, +) = filterFindUserMetadataForKey(pubKey, defaultRelays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index d206c94159..5401aeaa13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -20,18 +20,38 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterByEvent(eventId: HexKey) = - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = listOfNotNull(eventId), - ), - ), - ) +fun filterByEvent( + eventId: HexKey, + default: Set, +): List { + val note = LocalCache.checkGetOrCreateNote(eventId) ?: return emptyList() + + val list = + mapOfSet { + if (note !is AddressableNote && note.event == null) { + potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.idHex) + } + } + + // loads threading that is event-based + note.replyTo?.forEach { parentNote -> + if (parentNote !is AddressableNote && note.event == null) { + potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.idHex) + } + } + } + } + + return filterMissingEvents(list) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt index 3800ca745b..8c3a5c0956 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt @@ -20,21 +20,23 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -fun searchPeopleByName(searchString: HexKey) = - listOf( - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - search = searchString, - limit = 1000, - ), - ), - ) +fun searchPeopleByName( + searchString: HexKey, + relay: NormalizedRelayUrl, +) = listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + search = searchString, + limit = 1000, + ), + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 292f15f287..dd1cf3e629 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -20,26 +20,27 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -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.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent 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.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent @@ -47,62 +48,70 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -fun searchPostsByText(searchString: HexKey) = +val SearchPostsByTextKinds1 = listOf( - 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 = searchString, - 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 = searchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - FollowListEvent.KIND, - ), - search = searchString, - limit = 100, - ), - ), + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, ) + +val SearchPostsByTextKinds2 = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PublicMessageEvent.KIND, + NNSEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val SearchPostsByTextKinds3 = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + FollowListEvent.KIND, + ) + +fun searchPostsByText( + searchString: HexKey, + relay: NormalizedRelayUrl, +) = listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds1, + search = searchString, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds2, + search = searchString, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds3, + search = searchString, + limit = 100, + ), + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt index 2823f76c78..edd074b732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt @@ -23,21 +23,19 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassembli import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter 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.NNote 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.utils.Hex /** @@ -48,43 +46,54 @@ class SearchWatcherSubAssembler( val cache: LocalCache, client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: SearchQueryState, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { val mySearchString = key.searchQuery.value if (mySearchString.isBlank()) return null + val defaultRelays = key.account.followPlusAllMine.flow.value + val directFilters = runCatching { if (Hex.isHex(mySearchString)) { - val key = Hex.decode(mySearchString).toHexKey() - filterByAuthor(key) + filterByEvent(key) + val hexKey = Hex.decode(mySearchString).toHexKey() + filterByAuthor(hexKey, defaultRelays) + filterByEvent(hexKey, defaultRelays) } 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.justConsume(parsed.event, null, false) - emptyList() - } + val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity + if (parsed != null) { + cache.consume(parsed) - is NRelay -> emptyList() - is NAddress -> filterByAddress(parsed) - else -> emptyList() + when (parsed) { + is NSec -> filterByAuthor(parsed.toPubKeyHex(), defaultRelays) + is NPub -> filterByAuthor(parsed.hex, defaultRelays) + is NProfile -> filterByAuthor(parsed.hex, defaultRelays) + is NNote -> filterByEvent(parsed.hex, defaultRelays) + is NEvent -> filterByEvent(parsed.hex, defaultRelays) + is NEmbed -> emptyList() + is NRelay -> emptyList() + is NAddress -> filterByAddress(parsed, defaultRelays) + else -> emptyList() + } + } else { + emptyList() } } }.getOrDefault(emptyList()) - val searchFilters = searchPeopleByName(mySearchString) + searchPostsByText(mySearchString) + val searchFilters = + key.account.searchRelayList.flow.value.flatMap { + searchPeopleByName(mySearchString, it) + } + + key.account.searchRelayList.flow.value.flatMap { + searchPostsByText(mySearchString, it) + } return directFilters + searchFilters } - override fun id(key: SearchQueryState) = key.searchQuery.hashCode().toString() + override fun id(key: SearchQueryState) = key.searchQuery.hashCode() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt new file mode 100644 index 0000000000..6be8af457c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.speedLogger + +import android.util.Log +import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger.Companion.TAG +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.timer + +class FrameStat { + var eventCount = AtomicInteger(0) + var kinds = LargeCache() + + fun increment( + kind: Int, + subId: String, + relayUrl: NormalizedRelayUrl, + memory: Long, + ) { + eventCount.incrementAndGet() + + val kindGroup = kinds.get(kind) + if (kindGroup != null) { + kindGroup.increment(memory, subId, relayUrl) + } else { + val group = KindGroup() + group.increment(memory, subId, relayUrl) + kinds.put(kind, group) + } + } + + fun hasAnything() = eventCount.get() > 0 + + fun reset() { + eventCount.set(0) + kinds.forEach { key, value -> value.reset() } + } + + fun log() { + Log.d(TAG, "Events Per Second: ${eventCount.get()}") + kinds.forEach { key, value -> + if (value.count.get() > 0) { + Log.d(TAG, "-- Kind $key $value") + } + } + } + + init { + // Use a timer to reset the counter every second. + timer(name = "EventsPerSecondCounter", period = 1000, daemon = true) { + if (hasAnything()) { + log() + reset() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt new file mode 100644 index 0000000000..4fa495f174 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.speedLogger + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +@OptIn(ExperimentalAtomicApi::class) +class KindGroup( + var count: AtomicInteger = AtomicInteger(0), + var memory: AtomicLong = AtomicLong(0L), + val subs: LargeCache = LargeCache(), + val relays: LargeCache = LargeCache(), +) { + companion object { + const val MB: Long = 1024 + } + + fun increment( + mem: Long, + subId: String, + relayUrl: NormalizedRelayUrl, + ) { + count.incrementAndGet() + memory.addAndGet(mem) + + val subStats = subs.get(subId) + if (subStats != null) { + subStats.incrementAndGet() + } else { + subs.put(subId, AtomicInteger(1)) + } + + val relayStats = relays.get(relayUrl) + if (relayStats != null) { + relayStats.incrementAndGet() + } else { + relays.put(relayUrl, AtomicInteger(1)) + } + } + + fun reset() { + count.set(0) + memory.set(0L) + subs.forEach { key, value -> value.set(0) } + relays.forEach { key, value -> value.set(0) } + } + + fun printSubs() = subs.joinToString(", ") { key, value -> if (value.get() > 0) "$key ($value)" else "" } + + fun printRelays() = relays.joinToString(", ") { key, value -> if (value.get() > 0) "${key.displayUrl()} ($value)" else "" } + + override fun toString() = "(${count.get()} - ${memory.get().div(MB)}kb); ${printSubs()}; ${printRelays()}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt new file mode 100644 index 0000000000..8cbc9a1cb3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.speedLogger + +import android.util.Log +import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger.Companion.TAG +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelaySpeedLogger( + val client: NostrClient, +) { + companion object { + val TAG = RelaySpeedLogger::class.java.simpleName + } + + var current = FrameStat() + + private val clientListener = + object : IRelayClientListener { + /** A new message was received */ + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + current.increment(event.kind, subId, relay.url, event.countMemory()) + } + } + + init { + Log.d(TAG, "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d(TAG, "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index b230de2dac..4e529baac1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -22,22 +22,23 @@ package com.vitorpamplona.amethyst.service.relays import androidx.collection.LruCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +typealias SincePerRelayMap = Map class EOSERelayList { - var relayList: Map = emptyMap() + var relayList: SincePerRelayMap = emptyMap() fun addOrUpdate( - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val eose = relayList[relayUrl] if (eose == null) { - relayList = relayList + Pair(relayUrl, EOSETime(time)) + relayList = relayList + Pair(relayUrl, MutableTime(time)) } else { - if (time > eose.time) { - eose.update(time) - } + eose.updateIfNewer(time) } } @@ -48,19 +49,19 @@ class EOSERelayList { fun since() = relayList fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = addOrUpdate(relayUrl, time) + ) = addOrUpdate(relay, time) } -class EOSEFollowList( +open class EOSEByKey( cacheSize: Int = 200, ) { - var followList: LruCache = LruCache(cacheSize) + var followList: LruCache = LruCache(cacheSize) fun addOrUpdate( - listCode: String, - relayUrl: String, + listCode: U, + relayUrl: NormalizedRelayUrl, time: Long, ) { val relayList = followList[listCode] @@ -73,29 +74,29 @@ class EOSEFollowList( } } - fun since(listCode: String) = followList[listCode]?.relayList + fun since(listCode: U) = followList[listCode]?.relayList fun newEose( - listCode: String, - relayUrl: String, + listCode: U, + relayUrl: NormalizedRelayUrl, time: Long, ) = addOrUpdate(listCode, relayUrl, time) } -class EOSEAccount( +open class EOSEAccountKey( cacheSize: Int = 20, ) { - var users: LruCache = LruCache(cacheSize) + var users: LruCache> = LruCache>(cacheSize) fun addOrUpdate( user: User, - listCode: String, - relayUrl: String, + listCode: U, + relayUrl: NormalizedRelayUrl, time: Long, ) { val followList = users[user] if (followList == null) { - val newList = EOSEFollowList() + val newList = EOSEByKey() users.put(user, newList) newList.addOrUpdate(listCode, relayUrl, time) } else { @@ -109,13 +110,13 @@ class EOSEAccount( fun since( key: User, - listCode: String, + listCode: U, ) = users[key]?.followList?.get(listCode)?.relayList fun newEose( user: User, - listCode: String, - relayUrl: String, + listCode: U, + relayUrl: NormalizedRelayUrl, time: Long, ) = addOrUpdate(user, listCode, relayUrl, time) } @@ -127,7 +128,7 @@ class EOSEAccountFast( fun addOrUpdate( user: T, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val relayList = users[user] @@ -149,7 +150,7 @@ class EOSEAccountFast( fun newEose( user: T, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) = addOrUpdate(user, relayUrl, time) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 15b82ce307..4288337a21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -108,7 +108,7 @@ class MediaCompressor { // videoName = "compressed_video" // => required name // ), // OR AND NOT BOTH - appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), + storageConfiguration = AppSpecificStorageConfiguration(), configureWith = Configuration( quality = videoQuality, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 523545b7c5..424a3cd82f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -47,7 +47,6 @@ class MultiOrchestrator( fun first() = list.first() suspend fun upload( - scope: CoroutineScope, alt: String?, contentWarningReason: String?, mediaQuality: CompressorQuality, @@ -55,29 +54,30 @@ class MultiOrchestrator( account: Account, context: Context, ): Result { - val jobs = - list.map { item -> - scope.launch(Dispatchers.IO) { - item.orchestrator.upload( - item.media.uri, - item.media.mimeType, - alt, - contentWarningReason, - mediaQuality, - server, - account, - context, - ) + coroutineScope { + val jobs = + list.map { item -> + launch(Dispatchers.IO) { + item.orchestrator.upload( + item.media.uri, + item.media.mimeType, + alt, + contentWarningReason, + mediaQuality, + server, + account, + context, + ) + } } - } - jobs.joinAll() + jobs.joinAll() + } return computeFinalResults() } suspend fun uploadEncrypted( - scope: CoroutineScope, alt: String?, contentWarningReason: String?, mediaQuality: CompressorQuality, @@ -86,24 +86,25 @@ class MultiOrchestrator( account: Account, context: Context, ): Result { - val jobs = - list.map { item -> - scope.launch(Dispatchers.IO) { - item.orchestrator.uploadEncrypted( - item.media.uri, - item.media.mimeType, - alt, - contentWarningReason, - mediaQuality, - cipher, - server, - account, - context, - ) + coroutineScope { + val jobs = + list.map { item -> + launch(Dispatchers.IO) { + item.orchestrator.uploadEncrypted( + item.media.uri, + item.media.mimeType, + alt, + contentWarningReason, + mediaQuality, + cipher, + server, + account, + context, + ) + } } - } - - jobs.joinAll() + jobs.joinAll() + } return computeFinalResults() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 232ba281a6..4423bd16c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -151,7 +152,7 @@ class UploadOrchestrator { alt = alt, sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, onProgress = { percent: Float -> updateState(0.2 + (0.2 * percent), UploadingState.Uploading) }, @@ -164,8 +165,10 @@ class UploadOrchestrator { localContentType = contentType, originalContentType = contentTypeForResult, originalHash = originalHash, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, ) + } catch (_: SignerExceptions.ReadOnlyException) { + error(R.string.login_with_a_private_key_to_be_able_to_upload) } catch (e: Exception) { if (e is CancellationException) throw e error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) @@ -195,7 +198,7 @@ class UploadOrchestrator { alt = alt, sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -203,10 +206,12 @@ class UploadOrchestrator { verifyHeader( uploadResult = result, localContentType = contentType, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, originalHash = originalHash, originalContentType = contentTypeForResult, ) + } catch (_: SignerExceptions.ReadOnlyException) { + error(R.string.login_with_a_private_key_to_be_able_to_upload) } catch (e: Exception) { if (e is CancellationException) throw e error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 6b294ed187..7979093cbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -27,21 +27,23 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult -import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.coroutines.executeAsync import okio.BufferedSink import okio.source import java.io.File @@ -129,7 +131,7 @@ class BlossomUploader { ): MediaUploadResult { checkNotInMainThread() - val fileName = baseFileName ?: randomChars() + val fileName = baseFileName ?: RandomInstance.randomChars(16) val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" @@ -157,29 +159,28 @@ class BlossomUploader { requestBuilder .addHeader("Content-Length", length.toString()) - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(apiUrl) .put(requestBody) val request = requestBuilder.build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseResults(str) - return result - } - } else { - val errorMessage = response.headers.get("X-Reason") - - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) - } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.use { body -> + parseResults(body.string()) + } } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + val errorMessage = response.headers.get("X-Reason") + + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + } } } } @@ -208,20 +209,21 @@ class BlossomUploader { val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(apiUrl.removeSuffix("/") + "/$hash.$extension") .delete() .build() - okHttpClient(apiUrl).newCall(request).execute().use { response -> - if (response.isSuccessful) { - return true - } else { - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + return okHttpClient(apiUrl).newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + true } else { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 706544a0db..b6acdfde82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -27,7 +27,6 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.core.net.toFile import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -40,20 +39,20 @@ import com.vitorpamplona.quartz.nip96FileStorage.actions.PartialEvent import com.vitorpamplona.quartz.nip96FileStorage.actions.UploadResult import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.coroutines.executeAsync import okio.BufferedSink import okio.source import java.io.InputStream -val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - -fun randomChars() = List(16) { charPool.random() }.joinToString("") - class Nip96Uploader { suspend fun upload( uri: Uri, @@ -138,7 +137,7 @@ class Nip96Uploader { ): MediaUploadResult { checkNotInMainThread() - val fileName = randomChars() + val fileName = RandomInstance.randomChars(16) val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = okHttpClient(server.apiUrl) @@ -171,53 +170,54 @@ class Nip96Uploader { httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) } requestBuilder - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.apiUrl) .post(requestBody) val request = requestBuilder.build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val result = UploadResult.parse(body.string()) - if (!result.processingUrl.isNullOrBlank()) { - return waitProcessing(result, server, okHttpClient, onProgress) - } else if (result.status == "success") { - val event = result.nip94Event - if (event != null) { - return convertToMediaResult(event) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.use { body -> + val result = UploadResult.parse(body.string()) + if (!result.processingUrl.isNullOrBlank()) { + waitProcessing(result, server, okHttpClient, onProgress) + } else if (result.status == "success") { + val event = result.nip94Event + if (event != null) { + convertToMediaResult(event) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) + } } else { throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } - } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } - } - } else { - val msg = response.body.string() + } else { + val msg = response.body.string() - val errorMessage = - try { - val tree = jacksonObjectMapper().readTree(msg) - val status = tree.get("status")?.asText() - val message = tree.get("message")?.asText() - if (status == "error" && message != null) { - message - } else { + val errorMessage = + try { + val tree = jacksonObjectMapper().readTree(msg) + val status = tree.get("status")?.asText() + val message = tree.get("message")?.asText() + if (status == "error" && message != null) { + message + } else { + null + } + } catch (e: Exception) { null } - } catch (e: Exception) { - null - } - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) - } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) - } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) + } } } } @@ -278,23 +278,22 @@ class Nip96Uploader { val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") .delete() .build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val result = DeleteResult.parse(body.string()) - return result.status == "success" - } - } else { - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val result = DeleteResult.parse(response.body.string()) + result.status == "success" } else { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } @@ -316,14 +315,15 @@ class Nip96Uploader { val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(procUrl) .build() val client = okHttpClient(procUrl) - client.newCall(request).execute().use { - if (it.isSuccessful) { - it.body.use { currentResult = UploadResult.parse(it.string()) } + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + currentResult = UploadResult.parse(response.body.string()) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index f3abddafc8..55cf8147f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.service.uploads.nip96 import android.util.Log -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class ServerInfoRetriever { val parser = ServerInfoParser() @@ -42,24 +44,25 @@ class ServerInfoRetriever { .url(parser.assembleUrl(baseUrl)) .build() - okHttpClient(baseUrl).newCall(request).execute().use { response -> - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - return parser.parse(baseUrl, body) + val client = okHttpClient(baseUrl) + + return try { + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val body = response.body.string() + parser.parse(baseUrl, body) } else { - throw RuntimeException( + throw Exception( "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", ) } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) - throw e } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayInfoFail", "Resulting Message from $baseUrl", e) + throw e } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 916edcf225..8e0703715c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -34,29 +34,26 @@ import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.debugState +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent 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.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note -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.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -79,7 +76,7 @@ class MainActivity : AppCompatActivity() { val accountStateViewModel: AccountStateViewModel = viewModel() LaunchedEffect(key1 = Unit) { - accountStateViewModel.tryLoginExistingAccountAsync() + accountStateViewModel.loginWithDefaultAccountIfLoggedOff() } AccountScreen(accountStateViewModel, sharedPreferencesViewModel) @@ -145,7 +142,10 @@ class MainActivity : AppCompatActivity() { } } -fun uriToRoute(uri: String?): Route? = +fun uriToRoute( + uri: String?, + account: Account, +): Route? = if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) { Route.Notification } else { @@ -153,39 +153,40 @@ fun uriToRoute(uri: String?): Route? = Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=")) } else { val nip19 = Nip19Parser.uriToRoute(uri)?.entity + if (nip19 != null) { + LocalCache.consume(nip19) + } when (nip19) { is NPub -> Route.Profile(nip19.hex) is NProfile -> Route.Profile(nip19.hex) - is Note -> Route.Note(nip19.hex) + is NNote -> Route.Note(nip19.hex) is NEvent -> { - if (nip19.kind == PrivateDmEvent.KIND) { - nip19.author?.let { Route.RoomByAuthor(it) } - } else if ( - nip19.kind == ChannelMessageEvent.KIND || - nip19.kind == ChannelCreateEvent.KIND || - nip19.kind == ChannelMetadataEvent.KIND - ) { - Route.Channel(nip19.hex) - } else { - Route.EventRedirect(nip19.hex) - } + routeFor( + note = LocalCache.getOrCreateNote(nip19.hex), + loggedIn = account, + ) ?: Route.EventRedirect(nip19.hex) } is NAddress -> { - if (nip19.kind == CommunityDefinitionEvent.KIND) { - Route.Community(nip19.aTag()) - } else if (nip19.kind == LiveActivitiesEvent.KIND) { - Route.Channel(nip19.aTag()) - } else { - Route.EventRedirect(nip19.aTag()) - } + routeFor( + note = LocalCache.getOrCreateAddressableNote(nip19.address()), + loggedIn = account, + ) ?: Route.EventRedirect(nip19.aTag()) } is NEmbed -> { - if (LocalCache.getNoteIfExists(nip19.event.id) == null) { - LocalCache.justConsume(nip19.event, null, false) + val noteEvent = nip19.event + if (noteEvent is AddressableEvent) { + routeFor( + note = LocalCache.getOrCreateAddressableNote(noteEvent.address()), + loggedIn = account, + ) ?: Route.EventRedirect(noteEvent.addressTag()) + } else { + routeFor( + note = LocalCache.getOrCreateNote(nip19.event.id), + loggedIn = account, + ) ?: Route.EventRedirect(nip19.event.id) } - Route.EventRedirect(nip19.event.id) } else -> null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index c1a25736f6..55430dd5f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -37,7 +37,7 @@ private val resourceCache = LruCache(300) private var resourceCacheLanguage: String? = null // Caches most common icons in the app to avoid using disk -private val iconCache = LruCache(30) +private val iconCache = LruCache>(30) fun checkLanguage(currentLanguage: String) { if (resourceCacheLanguage == null) { @@ -127,19 +127,29 @@ fun stringRes( /** * This cache can only be used if the painter is the only copy on the screen + * It should store a separate Painter for each size. It's safe to just assume + * Different compositions use different sizes. */ @Composable fun painterRes( - @DrawableRes id: Int, + @DrawableRes resourceId: Int, + sizeReference: Int, ): Painter { - val cached = iconCache.get(id) + val cached = iconCache.get(resourceId) if (cached != null) { - return cached + val composition = cached.get(sizeReference) + if (composition != null) { + return composition + } } - val loaded = painterResource(id) + val loaded = painterResource(resourceId) - iconCache.put(id, loaded) + if (cached == null) { + iconCache.put(resourceId, LruCache(10)) + } else { + cached.put(sizeReference, loaded) + } return loaded } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 33dbb37759..66f922c901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -51,15 +49,11 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -87,26 +81,23 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.PostButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -126,8 +117,6 @@ fun EditPostView( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } LaunchedEffect(Unit) { postViewModel.load(edit, versionLookingAt, accountViewModel) @@ -142,70 +131,24 @@ fun EditPostView( decorFitsSystemWindows = false, ), ) { - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterRes(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - PostButton( - onPost = { - postViewModel.sendPost(relayList = relayList) - scope.launch { - delay(100) - onClose() - } - }, - isActive = postViewModel.canPost(), - ) + PostingTopBar( + isActive = postViewModel::canPost, + onPost = { + postViewModel.sendPost() + scope.launch { + delay(100) + onClose() } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancel() - scope.launch { - delay(100) - onClose() - } - }, - ) + onCancel = { + postViewModel.cancel() + scope.launch { + delay(100) + onClose() } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> @@ -341,11 +284,11 @@ fun EditPostView( ) { InvoiceRequest( lud16, - user.pubkeyHex, + user, accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it) postViewModel.wantsInvoice = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index f0000064b5..59f877edee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -45,9 +45,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestio import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener.onError +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -120,18 +122,21 @@ open class EditPostViewModel : ViewModel() { editedFromNote = edit } - fun sendPost(relayList: List) { - viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + fun sendPost() { + viewModelScope.launch(Dispatchers.IO) { innerSendPost() } } - suspend fun innerSendPost(relayList: List) { + suspend fun innerSendPost() { if (accountViewModel == null) { cancel() return } + val extraNotesToBroadcast = mutableListOf() + nip95attachments.forEach { - account?.sendNip95(it.first, it.second, relayList) + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) } val notify = @@ -147,7 +152,7 @@ open class EditPostViewModel : ViewModel() { originalNote = editedFromNote!!, notify = notify, summary = subject.text.ifBlank { null }, - relayList = relayList, + extraNotesToBroadcast, ) cancel() @@ -165,6 +170,23 @@ open class EditPostViewModel : ViewModel() { server: ServerName, onError: (String, String) -> Unit, context: Context, + ) = try { + uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + sensitiveContent: Boolean, + mediaQuality: Int, + isPrivate: Boolean = false, + server: ServerName, + onError: (String, String) -> Unit, + context: Context, ) { viewModelScope.launch { val myAccount = account ?: return@launch @@ -174,7 +196,6 @@ open class EditPostViewModel : ViewModel() { val results = myMultiOrchestrator.upload( - viewModelScope, alt, if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQuality), @@ -186,21 +207,21 @@ open class EditPostViewModel : ViewModel() { if (results.allGood) { results.successful.forEach { state -> if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95( - state.result.bytes, - headerInfo = state.result.fileHeader, - alt, - if (sensitiveContent) "" else null, - ) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val nip95 = + myAccount.createNip95( + byteArray = state.result.bytes, + headerInfo = state.result.fileHeader, + alt = alt, + contentWarningReason = if (sensitiveContent) "" else null, + ) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - } - - urlPreview = findUrlInMessage() + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) } + + urlPreview = findUrlInMessage() } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = IMetaTagBuilder(state.result.url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index d3e3e3b8ba..8a02c884f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -27,20 +27,19 @@ import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import androidx.core.net.toFile import androidx.core.net.toUri -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.PICTURES_SUBDIRECTORY import kotlinx.coroutines.CancellationException -import okhttp3.Call -import okhttp3.Callback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response +import okhttp3.coroutines.executeAsync import okio.BufferedSource -import okio.IOException import okio.buffer import okio.sink import okio.source @@ -48,16 +47,16 @@ import java.io.File import java.util.UUID object MediaSaverToDisk { - fun saveDownloadingIfNeeded( + suspend fun saveDownloadingIfNeeded( videoUri: String?, okHttpClient: (String) -> OkHttpClient, mimeType: String?, localContext: Context, onSuccess: () -> Any?, onError: (Throwable) -> Any?, - ) { + ) = withContext(Dispatchers.IO) { when { - videoUri.isNullOrBlank() -> return + videoUri.isNullOrBlank() -> return@withContext videoUri.startsWith("file") -> save( localFile = videoUri.toUri().toFile(), @@ -83,7 +82,7 @@ object MediaSaverToDisk { * * @see PICTURES_SUBDIRECTORY */ - fun downloadAndSave( + suspend fun downloadAndSave( url: String, mimeType: String?, okHttpClient: (String) -> OkHttpClient, @@ -92,67 +91,50 @@ object MediaSaverToDisk { onError: (Throwable) -> Any?, ) { val client = okHttpClient(url) - val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .get() .url(url) .build() - client - .newCall(request) - .enqueue( - object : Callback { - override fun onFailure( - call: Call, - e: IOException, - ) { - e.printStackTrace() - onError(e) - } + try { + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + check(response.isSuccessful) - override fun onResponse( - call: Call, - response: Response, - ) { - try { - check(response.isSuccessful) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) + check(contentType.isNotBlank()) { "Can't find out the content type" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) - check(contentType.isNotBlank()) { "Can't find out the content type" } - - val realType = - if (contentType == "application/octet-stream") { - mimeType ?: getMimeTypeFromExtension(url) - } else { - contentType - } - - saveContentQ( - displayName = File(trimInlineMetaData(url)).nameWithoutExtension, - contentType = realType, - contentSource = response.body.source(), - contentResolver = context.contentResolver, - ) + val realType = + if (contentType == "application/octet-stream") { + mimeType ?: getMimeTypeFromExtension(url) } else { - saveContentDefault( - fileName = File(trimInlineMetaData(url)).name, - contentSource = response.body.source(), - context = context, - ) + contentType } - onSuccess() - } catch (e: Exception) { - if (e is CancellationException) throw e - e.printStackTrace() - onError(e) - } + + saveContentQ( + displayName = File(trimInlineMetaData(url)).nameWithoutExtension, + contentType = realType, + contentSource = response.body.source(), + contentResolver = context.contentResolver, + ) + } else { + saveContentDefault( + fileName = File(trimInlineMetaData(url)).name, + contentSource = response.body.source(), + context = context, + ) } - }, - ) + onSuccess() + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("MediaSaverToDisk", "Error parsing response", e) + onError(e) + } } private fun getMimeTypeFromExtension(fileName: String): String = @@ -189,7 +171,7 @@ object MediaSaverToDisk { onSuccess() } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() + Log.w("MediaSaverToDisk", "Unable to save", e) onError(e) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index f00f3bf993..630f525738 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -39,14 +39,11 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.resume @Stable open class NewMediaModel : ViewModel() { @@ -82,13 +79,24 @@ open class NewMediaModel : ViewModel() { fun upload( context: Context, - relayList: List, + onSucess: () -> Unit, + onError: (String, String) -> Unit, + ) = try { + uploadUnsafe(context, onSucess, onError) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + context: Context, onSucess: () -> Unit, onError: (String, String) -> Unit, ) { viewModelScope.launch { val myAccount = account ?: return@launch - if (relayList.isEmpty()) return@launch val serverToUse = selectedServer ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch @@ -97,7 +105,6 @@ open class NewMediaModel : ViewModel() { val results = myMultiOrchestrator.upload( - viewModelScope, caption, if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQualitySlider), @@ -138,14 +145,8 @@ open class NewMediaModel : ViewModel() { nip95s.map { // upload each file as an individual nip95 event. viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) { nip95 -> - account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) - continuation.resume(true) - } - } - } + val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) + myAccount.consumeAndSendNip95(nip95.first, nip95.second) } } @@ -153,21 +154,14 @@ open class NewMediaModel : ViewModel() { videosAndOthers.map { // upload each file as an individual nip95 event. viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.sendHeader( - it.url, - it.magnet, - it.fileHeader, - caption, - if (sensitiveContent) "" else null, - it.uploadedHash, - relayList, - ) { - continuation.resume(true) - } - } - } + account?.sendHeader( + url = it.url, + magnetUri = it.magnet, + headerInfo = it.fileHeader, + alt = caption, + contentWarningReason = if (sensitiveContent) "" else null, + originalHash = it.uploadedHash, + ) } } @@ -175,18 +169,11 @@ open class NewMediaModel : ViewModel() { if (imageUrls.isNotEmpty()) { listOf( viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.sendAllAsOnePictureEvent( - imageUrls, - caption, - if (sensitiveContent) "" else null, - relayList, - ) { - continuation.resume(true) - } - } - } + account?.sendAllAsOnePictureEvent( + urlHeaderInfo = imageUrls, + caption = caption, + contentWarningReason = if (sensitiveContent) "" else null, + ) }, ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 1af3b03c25..643a72ace7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -23,8 +23,6 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -35,23 +33,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -66,18 +58,15 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.PostButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -100,9 +89,6 @@ fun NewMediaView( postViewModel.load(account, uris) } - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - Dialog( onDismissRequest = { onClose() }, properties = @@ -115,70 +101,23 @@ fun NewMediaView( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterRes(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } + PostingTopBar( + isActive = postViewModel::canPost, + onCancel = { + postViewModel.cancelModel() + onClose() + }, + onPost = { + postViewModel.upload(context, onClose, accountViewModel.toastManager::toast) + postViewModel.selectedServer?.let { + if (it.type != ServerType.NIP95) { + account.settings.changeDefaultFileServer(it) } - - PostButton( - onPost = { - postViewModel.upload(context, relayList, onClose, accountViewModel.toastManager::toast) - postViewModel.selectedServer?.let { - if (it.type != ServerType.NIP95) { - account.settings.changeDefaultFileServer(it) - } - } - }, - isActive = postViewModel.canPost(), - ) } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancelModel() - onClose() - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - Surface( modifier = Modifier @@ -186,8 +125,16 @@ fun NewMediaView( .consumeWindowInsets(pad) .imePadding(), ) { - Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { - Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { + Column( + Modifier + .fillMaxSize() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { ImageVideoPost(postViewModel, accountViewModel) } } @@ -202,7 +149,8 @@ fun ImageVideoPost( accountViewModel: AccountViewModel, ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList + .collectAsState() val fileServerOptions = remember(fileServers) { @@ -226,7 +174,11 @@ fun ImageVideoPost( OutlinedTextField( label = { Text(text = stringRes(R.string.add_caption)) }, - modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 3.dp) + .height(150.dp), maxLines = 10, value = postViewModel.caption, onValueChange = { postViewModel.caption = it }, @@ -245,7 +197,10 @@ fun ImageVideoPost( SettingSwitchItem( title = R.string.add_sensitive_content_label, description = R.string.add_sensitive_content_description, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), checked = postViewModel.sensitiveContent, onCheckedChange = { postViewModel.sensitiveContent = it }, ) @@ -264,7 +219,10 @@ fun ImageVideoPost( } Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index f9d0feeb57..9ac7dfe6bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -21,10 +21,12 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -42,7 +44,6 @@ class NewMessageTagger( var message: String, var pTags: List? = null, var eTags: List? = null, - var channelHex: String? = null, var dao: Dao, ) { val directMentions = mutableSetOf() @@ -73,12 +74,12 @@ class NewMessageTagger( is NPub -> addUserToMentions(dao.getOrCreateUser(entity.hex)) is NProfile -> addUserToMentions(dao.getOrCreateUser(entity.hex)) - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) + is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) is NEvent -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) is NEmbed -> addNoteToReplyTos(dao.getOrCreateNote(entity.event.id)) is NAddress -> { - val note = dao.checkGetOrCreateAddressableNote(entity.aTag()) + val note = dao.getOrCreateAddressableNote(entity.address()) if (note != null) { addNoteToReplyTos(note) } @@ -107,7 +108,7 @@ class NewMessageTagger( getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) } - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> { + is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> { getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord) } is NEvent -> { @@ -115,9 +116,9 @@ class NewMessageTagger( } is NAddress -> { - val note = dao.checkGetOrCreateAddressableNote(entity.aTag()) + val note = dao.getOrCreateAddressableNote(entity.address()) if (note != null) { - getNostrAddress(note.idNote(), results.restOfWord) + getNostrAddress(note.toNAddr(), results.restOfWord) } else { word } @@ -235,5 +236,5 @@ interface Dao { suspend fun getOrCreateNote(hex: String): Note - suspend fun checkGetOrCreateAddressableNote(hex: String): Note? + suspend fun getOrCreateAddressableNote(address: Address): AddressableNote? } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt index 99393183fe..1f97a05994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -66,7 +66,7 @@ fun NewPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, + colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid, label = { Text( text = stringRes(R.string.poll_zap_value_min), @@ -88,7 +88,7 @@ fun NewPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, + colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid, label = { Text( text = stringRes(R.string.poll_zap_value_max), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index 26af4a4ce0..6d8b8c1f58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions -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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -37,28 +35,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalMaterial3Api::class) @@ -68,62 +58,25 @@ fun NewUserMetadataScreen( accountViewModel: AccountViewModel, ) { val postViewModel: NewUserMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) val context = LocalContext.current - LaunchedEffect(Unit) { - postViewModel.load(accountViewModel.account) - } - - DisposableEffect(Unit) { - onDispose { - postViewModel.clear() - } + LaunchedEffect(accountViewModel) { + postViewModel.load() } Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.profile), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SaveButton( - onPost = { - postViewModel.create() - nav.popBack() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.profile, + onCancel = { + postViewModel.clear() + nav.popBack() }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - nav.popBack() - }, - ) - } + onPost = { + postViewModel.create() + nav.popBack() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 62f3ebfb65..5d0bc32227 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -35,16 +34,17 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException class NewUserMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account // val userName = mutableStateOf("") @@ -67,9 +67,12 @@ class NewUserMetadataViewModel : ViewModel() { var isUploadingImageForPicture by mutableStateOf(false) var isUploadingImageForBanner by mutableStateOf(false) - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + fun load() { account.userProfile().let { // userName.value = it.bestUsername() ?: "" displayName.value = it.info?.bestName() ?: "" @@ -99,21 +102,25 @@ class NewUserMetadataViewModel : ViewModel() { fun create() { // Tries to not delete any existing attribute that we do not work with. - viewModelScope.launch(Dispatchers.IO) { - account.sendNewUserMetadata( - name = displayName.value, - picture = picture.value, - banner = banner.value, - website = website.value, - pronouns = pronouns.value, - about = about.value, - nip05 = nip05.value, - lnAddress = lnAddress.value, - lnURL = lnURL.value, - twitter = twitter.value, - mastodon = mastodon.value, - github = github.value, - ) + accountViewModel.runIOCatching { + val metadata = + account.userMetadata.sendNewUserMetadata( + name = displayName.value, + picture = picture.value, + banner = banner.value, + website = website.value, + pronouns = pronouns.value, + about = about.value, + nip05 = nip05.value, + lnAddress = lnAddress.value, + lnURL = lnURL.value, + twitter = twitter.value, + mastodon = mastodon.value, + github = github.value, + ) + + account.sendLiterallyEverywhere(metadata) + clear() } } @@ -138,7 +145,7 @@ class NewUserMetadataViewModel : ViewModel() { context: Context, onError: (String, String) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { upload( uri, context, @@ -154,7 +161,7 @@ class NewUserMetadataViewModel : ViewModel() { context: Context, onError: (String, String) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { upload( uri, context, @@ -186,7 +193,7 @@ class NewUserMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = context, @@ -199,7 +206,7 @@ class NewUserMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -212,6 +219,9 @@ class NewUserMetadataViewModel : ViewModel() { onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) } catch (e: Exception) { if (e is CancellationException) throw e onUploading(false) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt deleted file mode 100644 index a07effd54e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -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.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -data class RelayList( - val relay: RelaySetupInfo, - val relayInfo: RelayBriefInfoCache.RelayBriefInfo, - val isSelected: Boolean, -) - -data class RelayInfoDialog( - val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - val relayInfo: Nip11RelayInformation, -) - -@Composable -fun RelaySelectionDialog( - preSelectedList: ImmutableList, - onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) = RelaySelectionDialogEasy( - preSelectedList.map { it.url }.toImmutableList(), - onClose, - onPost, - accountViewModel, - nav, -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RelaySelectionDialogEasy( - preSelectedList: ImmutableList, - onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val context = LocalContext.current - - var relays by remember { - mutableStateOf( - accountViewModel.account.connectToRelays.value.map { - RelayList( - relay = it, - relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), - isSelected = preSelectedList.any { relayUrl -> it.url == relayUrl }, - ) - }, - ) - } - - val hasSelectedRelay by remember { derivedStateOf { relays.any { it.isSelected } } } - - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - var selected by remember { mutableStateOf(true) } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - SetDialogToEdgeToEdge() - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - actions = { - SaveButton( - onPost = { - val selectedRelays = relays.filter { it.isSelected } - onPost(selectedRelays.map { it.relay }.toImmutableList()) - onClose() - }, - isActive = hasSelectedRelay, - ) - Spacer(modifier = StdHorzSpacer) - }, - title = {}, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { onClose() }, - ) - } - }, - ) - }, - ) { pad -> - Column( - modifier = - Modifier.padding(pad).padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) { - RelaySwitch( - text = stringRes(context, R.string.select_deselect_all), - checked = selected, - onClick = { - selected = !selected - relays = relays.mapIndexed { _, item -> item.copy(isSelected = selected) } - }, - ) - - LazyColumn( - contentPadding = FeedPadding, - ) { - itemsIndexed( - relays, - key = { _, item -> item.relay.url }, - ) { index, item -> - RelaySwitch( - text = item.relayInfo.displayUrl, - checked = item.isSelected, - onClick = { - relays = - relays.mapIndexed { j, item -> - if (index == j) { - item.copy(isSelected = !item.isSelected) - } else { - item - } - } - }, - onLongPress = { - accountViewModel.retrieveRelayDocument( - item.relay.url, - onInfo = { - relayInfo = - RelayInfoDialog( - RelayBriefInfoCache.RelayBriefInfo( - item.relay.url, - ), - it, - ) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - ) - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RelaySwitch( - text: String, - checked: Boolean, - onClick: () -> Unit, - onLongPress: () -> Unit = {}, -) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongPress, - ), - ) { - Text( - modifier = Modifier.weight(1f), - text = text, - ) - Switch( - checked = checked, - onCheckedChange = { onClick() }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 0beb600869..98887780e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -24,10 +24,7 @@ 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.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -35,32 +32,18 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults 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.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton import com.vitorpamplona.amethyst.ui.stringRes @@ -72,103 +55,6 @@ import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText -@Composable -fun MediaServersListView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val nip96ServersViewModel: NIP96ServersViewModel = viewModel() - val blossomServersViewModel: BlossomServersViewModel = viewModel() - - LaunchedEffect(key1 = Unit) { - nip96ServersViewModel.load(accountViewModel.account) - blossomServersViewModel.load(accountViewModel.account) - } - - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), - ) { - SetDialogToEdgeToEdge() - DialogContent(nip96ServersViewModel, blossomServersViewModel, onClose) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DialogContent( - nip96ServersViewModel: NIP96ServersViewModel, - blossomServersViewModel: BlossomServersViewModel, - onClose: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(id = R.string.media_servers), - style = MaterialTheme.typography.titleLarge, - ) - } - }, - navigationIcon = { - CloseButton( - onPress = { - nip96ServersViewModel.refresh() - blossomServersViewModel.refresh() - onClose() - }, - ) - }, - actions = { - SaveButton( - onPost = { - nip96ServersViewModel.saveFileServers() - blossomServersViewModel.saveFileServers() - onClose() - }, - isActive = true, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding( - start = 16.dp, - top = padding.calculateTopPadding(), - end = 16.dp, - bottom = padding.calculateBottomPadding(), - ).consumeWindowInsets(padding) - .imePadding(), - verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - stringRes(id = R.string.set_preferred_media_servers), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.grayText, - ) - - AllMediaBody(nip96ServersViewModel, blossomServersViewModel) - } - } -} - @Composable fun AllMediaBody( nip96ServersViewModel: NIP96ServersViewModel, @@ -182,32 +68,11 @@ fun AllMediaBody( horizontalAlignment = Alignment.CenterHorizontally, contentPadding = FeedPadding, ) { - item { - SettingsCategory( - stringRes(R.string.media_servers_nip96_section), - stringRes(R.string.media_servers_nip96_explainer), - SettingsCategoryFirstModifier, - ) - } - - renderMediaServerList( - mediaServersState = nip96ServersState, - keyType = "nip96", - editLabel = R.string.add_a_nip96_server, - emptyLabel = R.string.no_nip96_server_message, - onAddServer = { server -> - nip96ServersViewModel.addServer(server) - }, - onDeleteServer = { - nip96ServersViewModel.removeServer(serverUrl = it) - }, - ) - item { SettingsCategory( stringRes(R.string.media_servers_blossom_section), stringRes(R.string.media_servers_blossom_explainer), - SettingsCategorySpacingModifier, + SettingsCategoryFirstModifier, ) } @@ -224,6 +89,27 @@ fun AllMediaBody( }, ) + item { + SettingsCategory( + stringRes(R.string.media_servers_nip96_section), + stringRes(R.string.media_servers_nip96_explainer), + SettingsCategorySpacingModifier, + ) + } + + renderMediaServerList( + mediaServersState = nip96ServersState, + keyType = "nip96", + editLabel = R.string.add_a_nip96_server, + emptyLabel = R.string.no_nip96_server_message, + onAddServer = { server -> + nip96ServersViewModel.addServer(server) + }, + onDeleteServer = { + nip96ServersViewModel.removeServer(serverUrl = it) + }, + ) + DEFAULT_MEDIA_SERVERS.let { item { SettingsCategoryWithButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt new file mode 100644 index 0000000000..587fab25fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText + +@Composable +fun AllMediaServersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val nip96ServersViewModel: NIP96ServersViewModel = viewModel() + val blossomServersViewModel: BlossomServersViewModel = viewModel() + + LaunchedEffect(key1 = Unit) { + nip96ServersViewModel.load(accountViewModel.account) + blossomServersViewModel.load(accountViewModel.account) + } + + MediaServersScaffold(nip96ServersViewModel, blossomServersViewModel) { + nav.popBack() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaServersScaffold( + nip96ServersViewModel: NIP96ServersViewModel, + blossomServersViewModel: BlossomServersViewModel, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.media_servers, + onCancel = { + nip96ServersViewModel.refresh() + blossomServersViewModel.refresh() + onClose() + }, + onPost = { + nip96ServersViewModel.saveFileServers() + blossomServersViewModel.saveFileServers() + onClose() + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringRes(id = R.string.set_preferred_media_servers), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + AllMediaBody(nip96ServersViewModel, blossomServersViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 2a249fd45d..6eab5aadb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -127,5 +127,5 @@ class BlossomServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.getBlossomServersList()?.servers() + private fun obtainFileServers(): List? = account.blossomServers.flow.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt index 98cfae496f..0d8c32b19a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt @@ -122,5 +122,5 @@ class NIP96ServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.getFileServersList()?.servers() + private fun obtainFileServers(): List? = account.fileStorageServers.flow.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt new file mode 100644 index 0000000000..8f2eae9b8a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.Manifest +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RecordAudioBox( + modifier: Modifier, + onRecordTaken: (RecordingResult) -> Unit, + content: @Composable (Boolean) -> Unit, +) { + val mediaRecorder = remember { mutableStateOf(null) } + val context = LocalContext.current + + ClickAndHoldBoxComposable( + modifier = modifier, + onPress = { + val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + if (!recordPermissionState.status.isGranted) { + recordPermissionState.launchPermissionRequest() + } else { + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) + } + } + }, + onRelease = { + val result = mediaRecorder.value?.stop() + if (result != null) { + onRecordTaken(result) + } else { + // less disruptive than error messages + Toast + .makeText( + context, + stringRes(context, R.string.record_a_message_description), + Toast.LENGTH_SHORT, + ).show() + } + }, + onCancel = { + mediaRecorder.value?.stop() + }, + content, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt index 047336d139..b5675ee8a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt @@ -129,7 +129,7 @@ fun PictureButton(onClick: () -> Unit) { ) { Icon( imageVector = Icons.Default.CameraAlt, - contentDescription = stringRes(id = R.string.upload_image), + contentDescription = stringRes(id = R.string.take_a_picture), modifier = Modifier.height(22.dp), tint = MaterialTheme.colorScheme.onBackground, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt new file mode 100644 index 0000000000..ba98a19c45 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import androidx.media3.common.MimeTypes +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File + +class RecordingResult( + val file: File, + val mimeType: String, + val amplitudes: List, + val duration: Int, +) + +class VoiceMessageRecorder { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private var startTime: Long = 0 + private var job: Job? = null + private var amplitudes: MutableList = mutableListOf() + + private fun createRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + suspend fun start( + context: Context, + scope: CoroutineScope, + ) { + val fileName = RandomInstance.randomChars(16) + ".mp4" + val outputFile = File(context.cacheDir, "/voice/$fileName") + outputFile.parentFile?.mkdirs() + this.outputFile = outputFile + this.startTime = TimeUtils.now() + this.amplitudes.clear() + + createRecorder(context).apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(outputFile) + + prepare() + start() + + recorder = this + } + + job?.cancel() + job = + scope.launch { + while (recorder != null) { + amplitudes.add(((recorder?.maxAmplitude ?: 0) / 100).toInt()) + delay(1000) + } + } + } + + suspend fun stop(): RecordingResult? { + recorder?.stop() + recorder?.reset() + recorder = null + val currentTime = TimeUtils.now() + val file = outputFile + return if (currentTime - startTime >= 1 && file != null) { + RecordingResult( + file, + MimeTypes.AUDIO_AAC, + amplitudes, + (currentTime - startTime).toInt(), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 183cafa844..55ccb38714 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -56,8 +56,8 @@ import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.service.CachedCashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken +import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser +import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon @@ -82,10 +82,10 @@ fun CashuPreview( ) { @Suppress("ProduceStateDoesNotAssignValue") val cashuData by produceState( - initialValue = CachedCashuProcessor.cached(cashutoken), + initialValue = CachedCashuParser.cached(cashutoken), key1 = cashutoken, ) { - val newToken = withContext(Dispatchers.Default) { CachedCashuProcessor.parse(cashutoken) } + val newToken = withContext(Dispatchers.Default) { CachedCashuParser.parse(cashutoken) } if (value != newToken) { value = newToken } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 90110d3027..2015fefcb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -20,15 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.R.attr.onClick +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.semantics.Role import com.vitorpamplona.amethyst.ui.theme.ripple24dp @@ -51,6 +65,115 @@ fun ClickableBox( } } +@Composable +fun ClickAndHoldBox( + modifier: Modifier = Modifier, + onPress: () -> Unit, + onRelease: () -> Unit, + content: @Composable (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(isPressed) { + if (isPressed) { + // Button is pressed + onPress() + } else { + // Button is released + onRelease() + } + } + + // Animation for the button scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording + animationSpec = tween(durationMillis = 150), // Smooth animation + ) + + // Animation for the button color + val backgroundColor by animateColorAsState( + targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, + animationSpec = tween(durationMillis = 150), + ) + + Box( + modifier + .scale(scale) + .background(backgroundColor, CircleShape) + .clickable( + role = Role.Button, + interactionSource = interactionSource, + indication = ripple24dp, + onClick = { }, + ), + contentAlignment = Alignment.Center, + ) { + content(isPressed) + } +} + +@Composable +fun ClickAndHoldBoxComposable( + modifier: Modifier = Modifier, + onPress: @Composable () -> Unit, + onRelease: suspend () -> Unit, + onCancel: suspend () -> Unit, + content: @Composable (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + var isPressed by remember { mutableStateOf(false) } + + if (isPressed) { + onPress() + } + + LaunchedEffect(interactionSource) { + val pressInteractions = mutableListOf() + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> pressInteractions.add(interaction) + is PressInteraction.Release -> { + onRelease() + pressInteractions.remove(interaction.press) + } + is PressInteraction.Cancel -> { + onCancel() + pressInteractions.remove(interaction.press) + } + } + isPressed = pressInteractions.isNotEmpty() + } + } + + // Animation for the button scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording + animationSpec = tween(durationMillis = 150), // Smooth animation + ) + + // Animation for the button color + val backgroundColor by animateColorAsState( + targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, + animationSpec = tween(durationMillis = 150), + ) + + Box( + modifier + .scale(scale) + .background(backgroundColor, CircleShape) + .clickable( + role = Role.Button, + interactionSource = interactionSource, + indication = ripple24dp, + onClick = { }, + ), + contentAlignment = Alignment.Center, + ) { + content(isPressed) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun ClickableBox( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 047ab7398a..1e304ab32e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -30,7 +30,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.mutableStateOf import androidx.compose.runtime.remember @@ -57,30 +56,27 @@ 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 -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent 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.NNote 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.toNIP19 -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -95,19 +91,13 @@ fun ClickableRoute( when (val entity = nip19.entity) { is NPub -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) is NProfile -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> DisplayEvent(entity.hex, null, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) - is NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) + is NNote -> DisplayEvent(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) + is NEvent -> DisplayEvent(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) is NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav) is NAddress -> DisplayAddress(entity, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) - is NRelay -> { - Text(word) - } - is NSec -> { - Text(word) - } - else -> { - Text(word) - } + is NRelay -> Text(word) + is NSec -> Text(word) + else -> Text(word) } } @@ -138,7 +128,7 @@ private fun LoadAndDisplayEvent( ) { LoadOrCreateNote(event, accountViewModel) { if (it != null) { - DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav) + DisplayNoteLink(it, event.id, additionalChars, accountViewModel, nav) } else { val externalLink = event.toNIP19() val uri = LocalUriHandler.current @@ -158,7 +148,6 @@ private fun LoadAndDisplayEvent( @Composable fun DisplayEvent( hex: HexKey, - kind: Int?, nip19: String, additionalChars: String?, accountViewModel: AccountViewModel, @@ -166,7 +155,7 @@ fun DisplayEvent( ) { LoadNote(hex, accountViewModel) { if (it != null) { - DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav) + DisplayNoteLink(it, hex, additionalChars, accountViewModel, nav) } else { val externalLink = njumpLink(nip19) val uri = LocalUriHandler.current @@ -187,55 +176,21 @@ fun DisplayEvent( private fun DisplayNoteLink( it: Note, hex: HexKey, - kind: Int?, addedCharts: String?, accountViewModel: AccountViewModel, nav: INav, ) { val noteState by observeNote(it, accountViewModel) - val note = remember(noteState) { noteState?.note } ?: return + val noteIdDisplayNote = remember(noteState) { "@${noteState.note.idDisplayNote()}" } - val channelHex = remember(noteState) { note.channelHex() } - val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } + val route = routeFor(it, accountViewModel.account) ?: Route.EventRedirect(hex) - if (note.event is ChannelCreateEvent || kind == ChannelCreateEvent.KIND) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { Route.Channel(hex) }, - nav = nav, - ) - } else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = - remember(noteState) { (note.author?.pubkeyHex ?: hex).let { Route.RoomByAuthor(it) } }, - nav = nav, - ) - } else if (channelHex != null) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> - val channelState by observeChannel(baseChannel, accountViewModel) - val channelDisplayName by - remember(channelState) { - derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote } - } - - CreateClickableText( - clickablePart = channelDisplayName, - suffix = addedCharts, - route = remember(noteState) { Route.Channel(baseChannel.idHex) }, - nav = nav, - ) - } - } else { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { Route.EventRedirect(hex) }, - nav = nav, - ) - } + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = route, + nav = nav, + ) } @Composable @@ -250,7 +205,7 @@ private fun DisplayAddress( if (noteBase == null) { LaunchedEffect(key1 = nip19) { - accountViewModel.checkGetOrCreateAddressableNote(nip19.aTag()) { noteBase = it } + noteBase = accountViewModel.getOrCreateAddressableNote(nip19.address()) } } @@ -258,7 +213,7 @@ private fun DisplayAddress( val noteState by observeNote(it, accountViewModel) val route = remember(noteState) { Route.Note(nip19.aTag()) } - val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } + val displayName = remember(noteState) { "@${noteState.note.idDisplayNote()}" } CreateClickableText( clickablePart = displayName, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 77ebb7bfe3..6d6b55a9c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index 7a2e170754..4e847ec37f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -53,7 +53,8 @@ fun MyAsyncImage( mainImageModifier: Modifier, loadedImageModifier: Modifier, accountViewModel: AccountViewModel, - onError: @Composable () -> Unit, + onLoadingBackground: (@Composable () -> Unit)?, + onError: (@Composable () -> Unit)?, ) { val ratio = MediaAspectRatioCache.get(imageUrl) val showImage = remember { mutableStateOf(accountViewModel.settings.showImages.value) } @@ -75,12 +76,31 @@ fun MyAsyncImage( } } else { WaitAndDisplay { - DisplayUrlWithLoadingSymbol(imageUrl) + if (onLoadingBackground != null) { + Box(loadedImageModifier, contentAlignment = Alignment.Center) { + onLoadingBackground() + LoadingAnimation(Size40dp, Size6dp) + } + } else { + DisplayUrlWithLoadingSymbol(imageUrl) + } } } } is AsyncImagePainter.State.Error -> { - onError() + if (onError != null) { + if (ratio != null) { + Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) { + onError() + } + } else { + Box(loadedImageModifier, contentAlignment = Alignment.Center) { + onError() + } + } + } else { + ClickableUrl(urlText = imageUrl, url = imageUrl) + } } is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent(loadedImageModifier) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 41d68ea817..da20d34cd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.model.HashtagIcon +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon @@ -88,22 +89,25 @@ 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 -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview -import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -164,6 +168,7 @@ fun RenderStrangeNamePreview() { @Composable fun RenderRegularPreview() { val nav = EmptyNav + val accountViewModel = mockAccountViewModel() Column(modifier = Modifier.padding(10.dp)) { RenderRegular( @@ -190,7 +195,7 @@ fun RenderRegularPreview() { ) } - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -204,6 +209,7 @@ fun RenderRegularPreview() { @Composable fun RenderRegularPreview2() { val nav = EmptyNav + val accountViewModel = mockAccountViewModel() RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, @@ -218,7 +224,7 @@ fun RenderRegularPreview2() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -259,7 +265,7 @@ fun RenderRegularPreview3() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -386,7 +392,7 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -415,7 +421,7 @@ private fun RenderWordWithPreview( is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -620,6 +626,7 @@ fun CoreSecretMessage( @Composable fun HashTag( segment: HashTagSegment, + accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary @@ -724,7 +731,7 @@ fun TagLink( ) { LoadNote(baseNoteHex = word.hex, accountViewModel) { if (it == null) { - Text(text = remember { word.segmentText.toShortenHex() }) + Text(text = remember { word.segmentText.toShortDisplay() }) } else { Row { DisplayNoteFromTag( @@ -741,6 +748,32 @@ fun TagLink( } } +@Preview +@Composable +fun DisplayNoteFromTagPreview() { + val dummyPost = + TextNoteEvent( + id = "0b6d941c46411a95edb1c93da7ad6ca26370497d8c7b7d621f5cb59f48841bad", + pubKey = "6dd3b72e325da7383b275eef1c66131ba4664326e162bc060527509b4e33ae43", + createdAt = 1753988264, + tags = emptyArray(), + content = "test", + sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", + ) + + LocalCache.justConsume(dummyPost, null, true) + val note = LocalCache.getOrCreateNote(dummyPost.id) + + ThemeComparisonColumn( + toPreview = { + ClickableTextPrimary( + text = "@${note.idNote().toShortDisplay()}", + onClick = { }, + ) + }, + ) +} + @Composable private fun DisplayNoteFromTag( baseNote: Note, @@ -763,8 +796,8 @@ private fun DisplayNoteFromTag( ) } else { ClickableTextPrimary( - text = "@${baseNote.idNote().toShortenHex()}", - onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, + text = "@${baseNote.idNote().toShortDisplay()}", + onClick = { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 087ec6b503..bfc5b8a36e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -265,7 +265,7 @@ private fun DialogContent( Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted ) { - scope.launch { + scope.launch(Dispatchers.IO) { saveMediaToGallery(myContent, localContext, accountViewModel) } scope.launch { @@ -295,7 +295,7 @@ private fun DialogContent( } } -private fun saveMediaToGallery( +private suspend fun saveMediaToGallery( content: BaseMediaContent, localContext: Context, accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index ea77ff3b9b..1b79f32b33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -36,8 +36,11 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -86,13 +89,12 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon -import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon -import com.vitorpamplona.amethyst.ui.note.HashCheckIcon +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size30dp +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.Size6dp import com.vitorpamplona.amethyst.ui.theme.Size75dp @@ -837,7 +839,12 @@ private fun HashVerificationSymbol(verifiedHash: Boolean) { openDialogMsg.value = stringRes(localContext, R.string.hash_verification_passed) }, ) { - HashCheckIcon(Size30dp) + Icon( + painter = painterRes(R.drawable.original, 1), + contentDescription = stringRes(id = R.string.hash_verification_passed), + modifier = Size30Modifier, + tint = Color.Unspecified, + ) } } else { IconButton( @@ -846,7 +853,12 @@ private fun HashVerificationSymbol(verifiedHash: Boolean) { openDialogMsg.value = stringRes(localContext, R.string.hash_verification_failed) }, ) { - HashCheckFailedIcon(Size30dp) + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringRes(id = R.string.hash_verification_failed), + modifier = Size30Modifier, + tint = Color.Red, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index e5784f8170..70468d31ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayFullNote import com.vitorpamplona.amethyst.ui.components.DisplayUser import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink import com.vitorpamplona.amethyst.ui.theme.Font17SP @@ -171,7 +171,7 @@ class MarkdownMediaRenderer( when (val entity = loadedLink.nip19.entity) { is NPub -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder) is NProfile -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder) - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) + is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) is NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) is NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) is NAddress -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index cc4f5356b8..a70b427f20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -41,8 +41,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.service.previews.UrlInfoItem import com.vitorpamplona.amethyst.ui.components.UrlPreviewState -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle @@ -73,7 +73,7 @@ fun RenderContentAsMarkdown( val onClick = remember { { link: String -> - val route = uriToRoute(link) + val route = uriToRoute(link, accountViewModel.account) if (route != null) { nav.nav(route) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt index abbf31e51a..2f0fd947af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt @@ -25,7 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiErrorToastMsg import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiUserErrorMessageDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt index 89dabc657d..ae1d16be90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt @@ -44,9 +44,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -140,7 +140,7 @@ fun ErrorRow( } ?: stringRes(R.string.error_dialog_talk_to_user) Icon( - painter = painterRes(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 2), contentDescription = descriptor, modifier = Size20Modifier, tint = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt index b4546a4bc0..85e7cc817d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index abd5be836b..d6a71a6c2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -32,10 +32,10 @@ abstract class FeedFilter { return feed.take(limit()) } - open fun limit() = 1000 + open fun limit() = 500 /** Returns a string that serves as the key to invalidate the list if it changes. */ - abstract fun feedKey(): String + abstract fun feedKey(): Any open fun showHiddenKey(): Boolean = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 3243d283ee..7ecbe13393 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -20,26 +20,23 @@ */ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.AROUND_ME -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( - val isGlobal: Boolean, val isHiddenList: Boolean, - val isAroundMe: Boolean, - val followLists: Account.LiveFollowList?, - val hiddenLists: Account.LiveHiddenUsers, + val followLists: IFeedTopNavFilter?, + val hiddenLists: HiddenUsersState.LiveHiddenUsers, val now: Long = TimeUtils.oneMinuteFromNow(), ) { fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex)) @@ -48,45 +45,39 @@ class FilterByListParams( fun isEventInList(noteEvent: Event): Boolean { if (followLists == null) return false - if (isAroundMe && followLists.geotags.isEmpty()) return false - return if (noteEvent is LiveActivitiesEvent) { - noteEvent.participantsIntersect(followLists.authors) || - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } else if (noteEvent is CommentEvent) { - // ignore follows and checks only the root scope - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedScopes(followLists.hashtagScopes) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedScopes(followLists.geotagScopes) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } else { - noteEvent.pubKey in followLists.authors || - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } + return followLists.match(noteEvent) + } + + fun isAuthorInFollows(author: HexKey): Boolean { + if (followLists == null) return false + + return followLists.matchAuthor(author) } fun isAuthorInFollows(address: Address): Boolean { if (followLists == null) return false - return address.pubKeyHex in followLists.authors + return followLists.matchAuthor(address.pubKeyHex) } + fun isGlobal(comingFrom: List) = + followLists is GlobalTopNavFilter && + comingFrom.any { followLists.outboxRelays.value.contains(it) } + fun match( noteEvent: Event, - isGlobalRelay: Boolean = true, - ) = ((isGlobal && isGlobalRelay) || isEventInList(noteEvent)) && + comingFrom: List = emptyList(), + ) = ((isGlobal(comingFrom)) || isEventInList(noteEvent)) && (isHiddenList || isNotHidden(noteEvent.pubKey)) && isNotInTheFuture(noteEvent) - fun match(address: Address?) = - address != null && - (isGlobal || isAuthorInFollows(address)) && - (isHiddenList || isNotHidden(address.pubKeyHex)) + fun match( + address: Address?, + comingFrom: List = emptyList(), + ) = address != null && + (isGlobal(comingFrom) || isAuthorInFollows(address)) && + (isHiddenList || isNotHidden(address.pubKeyHex)) companion object { fun showHiddenKey( @@ -95,15 +86,11 @@ class FilterByListParams( ) = selectedListName == PeopleListEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex) fun create( - userHex: String, - selectedListName: String, - followLists: Account.LiveFollowList?, - hiddenUsers: Account.LiveHiddenUsers, + followLists: IFeedTopNavFilter?, + hiddenUsers: HiddenUsersState.LiveHiddenUsers, ): FilterByListParams = FilterByListParams( - isGlobal = selectedListName == GLOBAL_FOLLOWS, - isHiddenList = showHiddenKey(selectedListName, userHex), - isAroundMe = selectedListName == AROUND_ME, + isHiddenList = followLists is MutedAuthorsByOutboxTopNavFilter || followLists is MutedAuthorsByProxyTopNavFilter, followLists = followLists, hiddenLists = hiddenUsers, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt index 0ea301bce8..fa561cbf6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -24,8 +24,8 @@ import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -38,11 +38,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlin.collections.distinctBy @Stable class ChannelFeedContentState( - val localFilter: AdditiveComplexFeedFilter, + val localFilter: AdditiveComplexFeedFilter, val viewModelScope: CoroutineScope, ) : InvalidatableContent { private val _feedContent = MutableStateFlow(ChannelFeedState.Loading) @@ -53,7 +52,7 @@ class ChannelFeedContentState( val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: Any? = null override val isRefreshing: MutableState = mutableStateOf(false) @@ -78,7 +77,7 @@ class ChannelFeedContentState( isRefreshing.value = true try { lastFeedKey = localFilter.feedKey() - val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList() + val notes = localFilter.loadTop().distinctBy { it }.toImmutableList() val oldNotesState = _feedContent.value if (oldNotesState is ChannelFeedState.Loaded) { @@ -93,15 +92,15 @@ class ChannelFeedContentState( } } - private fun updateFeed(notes: ImmutableList) { + private fun updateFeed(notes: ImmutableList) { val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.tryEmit(ChannelFeedState.Empty) } else if (currentState is ChannelFeedState.Loaded) { - currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey())) + currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey())) } else { _feedContent.tryEmit( - ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))), + ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))), ) } } @@ -123,7 +122,7 @@ class ChannelFeedContentState( val newList = localFilter .updateListWith(emptyList(), newItems) - .distinctBy { it.idHex } + .distinctBy { it } .toImmutableList() if (newList.isNotEmpty()) { updateFeed(newList) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt index 123a3e4d6d..1c6fa31587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import kotlinx.coroutines.flow.MutableStateFlow @Stable @@ -29,7 +29,7 @@ sealed class ChannelFeedState { object Loading : ChannelFeedState() class Loaded( - val feed: MutableStateFlow>, + val feed: MutableStateFlow>, ) : ChannelFeedState() object Empty : ChannelFeedState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt index ffc7656e3e..b2410ce850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt @@ -53,10 +53,12 @@ class FeedContentState( val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: Any? = null override val isRefreshing: MutableState = mutableStateOf(false) + val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow(null) + fun sendToTop() { if (scrolltoTopPending) return @@ -72,6 +74,17 @@ class FeedContentState( viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } + fun visibleNotes(): List { + val currentState = _feedContent.value + return if (currentState is FeedState.Loaded) { + currentState.feed.value.list + } else { + emptyList() + } + } + + fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value + fun refreshSuspended() { checkNotInMainThread() @@ -94,6 +107,13 @@ class FeedContentState( } private fun updateFeed(notes: ImmutableList) { + if (notes.size >= localFilter.limit()) { + val lastNomeTime = notes.lastOrNull { it.event != null }?.createdAt() + if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { + lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNomeTime) + } + } + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.tryEmit(FeedState.Empty) @@ -106,6 +126,13 @@ class FeedContentState( } } + fun deleteFromFeed(deletedNotes: Set) { + val feed = _feedContent.value + if (feed is FeedState.Loaded) { + updateFeed((feed.feed.value.list - deletedNotes).toImmutableList()) + } + } + fun refreshFromOldState(newItems: Set) { val oldNotesState = _feedContent.value if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index f0d80ffba6..31aa9f1b46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index d3dadab569..eae3d24525 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -32,7 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 9cc2040462..204e28356d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -56,7 +56,7 @@ fun ChannelNamePreview() { ChatHeaderLayout( channelPicture = { Image( - painter = painterRes(R.drawable.github), + painter = painterRes(R.drawable.github, 1), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, ) @@ -89,7 +89,7 @@ fun ChannelNamePreview() { }, leadingContent = { Image( - painter = painterRes(R.drawable.github), + painter = painterRes(R.drawable.github, 2), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = Size55Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt index 51dac9e05d..50a4a6c6f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt @@ -65,7 +65,7 @@ fun LeftPictureLayoutPreviewCard() { LeftPictureLayout( onImage = { Image( - painter = painterRes(R.drawable.github), + painter = painterRes(R.drawable.github, 3), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxSize().clip(QuoteBorder), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 48276a4c7f..bcc6cc9f90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -26,12 +26,6 @@ import android.os.Parcelable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -48,8 +42,15 @@ 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.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments +import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute +import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel @@ -59,10 +60,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.EphemeralChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen @@ -76,10 +78,12 @@ 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.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen 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.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen @@ -90,6 +94,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -126,18 +132,27 @@ fun AppNavigation( composableFromEnd { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromBottomArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { AllRelayListScreen(it.toAdd, accountViewModel, nav) } + composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { HashtagScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { GeoHashScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { CommunityScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - composableFromEndArgs { ChannelScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { EphemeralChatScreen(RoomId(it.id, it.relayUrl), accountViewModel, nav) } + + composableFromEndArgs { PublicChatChannelScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { LiveActivityChannelScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { + RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay -> + EphemeralChatScreen(RoomId(it.id, relay), accountViewModel, nav) + } + } composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } @@ -158,6 +173,14 @@ fun AppNavigation( ) } + composableFromBottomArgs { + NewPublicMessageScreen( + to = it.toKey(), + accountViewModel, + nav, + ) + } + composableFromBottomArgs { HashtagPostScreen( hashtag = it.hashtag, @@ -267,15 +290,15 @@ private fun NavigateIfIntentRequested( currentIntentNextPage?.let { intentNextPage -> var actionableNextPage by remember { - mutableStateOf(uriToRoute(intentNextPage)) + mutableStateOf(uriToRoute(intentNextPage, accountViewModel.account)) } LaunchedEffect(intentNextPage) { if (actionableNextPage != null) { actionableNextPage?.let { nextRoute -> val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() - if (npub != null && accountStateViewModel.currentAccount() != npub) { - accountStateViewModel.switchUserSync(npub, nextRoute) + if (npub != null && accountStateViewModel.currentAccountNPub() != npub) { + accountStateViewModel.checkAndSwitchUserSync(npub, nextRoute) } else { val currentRoute = getRouteWithArguments(nav.controller) if (!isSameRoute(currentRoute, nextRoute)) { @@ -325,13 +348,13 @@ private fun NavigateIfIntentRequested( if (!uri.isNullOrBlank()) { // navigation functions - val newPage = uriToRoute(uri) + val newPage = uriToRoute(uri, accountViewModel.account) if (newPage != null) { scope.launch { val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() - if (npub != null && accountStateViewModel.currentAccount() != npub) { - accountStateViewModel.switchUserSync(npub, newPage) + if (npub != null && accountStateViewModel.currentAccountNPub() != npub) { + accountStateViewModel.checkAndSwitchUserSync(npub, newPage) } else { val currentRoute = getRouteWithArguments(nav.controller) if (!isSameRoute(currentRoute, newPage)) { @@ -367,36 +390,6 @@ private fun NavigateIfIntentRequested( } } -private fun isSameRoute( - currentRoute: Route?, - newRoute: Route, -): Boolean { - if (currentRoute == null) return false - - if (currentRoute == newRoute) { - return true - } - - if (newRoute is Route.EventRedirect) { - return when (currentRoute) { - is Route.Note -> newRoute.id == currentRoute.id - is Route.Channel -> newRoute.id == currentRoute.id - else -> false - } - } - - return false -} - -val slideInVerticallyFromBottom = slideInVertically(animationSpec = tween(), initialOffsetY = { it }) -val slideOutVerticallyToBottom = slideOutVertically(animationSpec = tween(), targetOffsetY = { it }) - -val slideInHorizontallyFromEnd = slideInHorizontally(animationSpec = tween(), initialOffsetX = { it }) -val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), targetOffsetX = { it }) - -val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) -val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) - fun URI.findParameterValue(parameterName: String): String? = rawQuery ?.split('&') diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/HeightDecreaser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/HeightDecreaser.kt deleted file mode 100644 index 2c28c21e94..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/HeightDecreaser.kt +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.navigation - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.DecayAnimationSpec -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.TopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.platform.LocalDensity -import com.vitorpamplona.amethyst.ui.theme.TopBarSize - -// This is a hack to decrease the height for the -// default TopBar without having to reimplement it. - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun rememberHeightDecreaser(): TopAppBarScrollBehavior { - val height = - LocalDensity.current.run { - TopBarSize.toPx() - } - - return remember(height) { - HeightDecreaser(height) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -class HeightDecreaser( - height: Float, -) : TopAppBarScrollBehavior { - override val state: TopAppBarState = - TopAppBarState( - initialHeightOffsetLimit = -Float.MAX_VALUE, - initialHeightOffset = 0f, - initialContentOffset = 0f, - ).also { - it.heightOffset = height - } - - override val isPinned: Boolean = true - override val snapAnimationSpec: AnimationSpec? = null - override val flingAnimationSpec: DecayAnimationSpec? = null - override var nestedScrollConnection = - object : NestedScrollConnection { - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset = available - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index 120331cd29..caf2f71c64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -21,6 +21,13 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder @@ -64,3 +71,12 @@ inline fun NavGraphBuilder.composableArgs(noinline content: @C content(it.toRoute()) } } + +val slideInVerticallyFromBottom = slideInVertically(animationSpec = tween(), initialOffsetY = { it }) +val slideOutVerticallyToBottom = slideOutVertically(animationSpec = tween(), targetOffsetY = { it }) + +val slideInHorizontallyFromEnd = slideInHorizontally(animationSpec = tween(), initialOffsetX = { it }) +val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), targetOffsetX = { it }) + +val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) +val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index c244e81161..3ca3ad65dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -18,11 +18,8 @@ * 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.navigation +package com.vitorpamplona.amethyst.ui.navigation.bottombars -import android.graphics.Rect -import android.view.View -import android.view.ViewTreeObserver import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +27,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomAppBarDefaults.windowInsets import androidx.compose.material3.HorizontalDivider @@ -39,85 +35,19 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size0dp import com.vitorpamplona.amethyst.ui.theme.Size10Modifier -import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import kotlinx.collections.immutable.persistentListOf - -val bottomNavigationItems = - persistentListOf( - BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)), - BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), - BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), - BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), - BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications), - ) - -enum class Keyboard { - Opened, - Closed, -} - -fun isKeyboardOpen(view: View): Keyboard { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - - return if (keypadHeight > screenHeight * 0.15) { - Keyboard.Opened - } else { - Keyboard.Closed - } -} - -@Composable -fun keyboardAsState(): State { - val view = LocalView.current - - val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } - - DisposableEffect(view) { - val onGlobalListener = - ViewTreeObserver.OnGlobalLayoutListener { - val newKeyboardValue = isKeyboardOpen(view) - - if (newKeyboardValue != keyboardState.value) { - keyboardState.value = newKeyboardValue - } - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - - onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } - } - - return keyboardState -} - -@Composable -fun IfKeyboardClosed(inner: @Composable () -> Unit) { - val isKeyboardState by keyboardAsState() - if (isKeyboardState == Keyboard.Closed) { - inner() - } -} @Composable fun AppBottomBar( @@ -125,7 +55,10 @@ fun AppBottomBar( accountViewModel: AccountViewModel, nav: (Route) -> Unit, ) { - IfKeyboardClosed { RenderBottomMenu(selectedRoute, accountViewModel, nav) } + val isKeyboardState by keyboardAsState() + if (isKeyboardState == KeyboardState.Closed) { + RenderBottomMenu(selectedRoute, accountViewModel, nav) + } } @Composable @@ -185,7 +118,7 @@ private fun NotifiableIcon( ) { Box(route.notifSize) { Icon( - painter = painterRes(id = route.icon), + painter = painterRes(resourceId = route.icon, 0), contentDescription = stringRes(route.contentDescriptor), modifier = route.iconSize, tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt new file mode 100644 index 0000000000..16dc2c9905 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size23dp +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import kotlinx.collections.immutable.persistentListOf + +class BottomBarRoute( + val route: Route, + val icon: Int, + val contentDescriptor: Int, + val notifSize: Modifier = Modifier.size(Size23dp), + val iconSize: Modifier = Modifier.size(Size20dp), +) + +val bottomNavigationItems = + persistentListOf( + BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)), + BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), + BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), + BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), + BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt new file mode 100644 index 0000000000..ce874a519e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import android.graphics.Rect +import android.view.View +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView + +enum class KeyboardState { + Opened, + Closed, +} + +@Composable +fun keyboardAsState(): State { + val view = LocalView.current + + val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } + + DisposableEffect(view) { + val onGlobalListener = + ViewTreeObserver.OnGlobalLayoutListener { + val newKeyboardValue = isKeyboardOpen(view) + + if (newKeyboardValue != keyboardState.value) { + keyboardState.value = newKeyboardValue + } + } + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } + } + + return keyboardState +} + +fun isKeyboardOpen(view: View): KeyboardState { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom + + return if (keypadHeight > screenHeight * 0.15) { + KeyboardState.Opened + } else { + KeyboardState.Closed + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index ca3e7da9c8..ca1f0cb773 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -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.navigation +package com.vitorpamplona.amethyst.ui.navigation.drawer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse 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 +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog @@ -242,7 +242,7 @@ private fun AccountName( } Text( - text = remember(user) { acc.npub.toShortenHex() }, + text = remember(user) { acc.npub.toShortDisplay() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index f52aaed5e5..03a1fe521f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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.navigation +package com.vitorpamplona.amethyst.ui.navigation.drawer import androidx.compose.foundation.Image import androidx.compose.foundation.border @@ -96,9 +96,11 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount 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.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -119,8 +121,8 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier -import com.vitorpamplona.ammolite.relays.RelayPoolStatus import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists @@ -225,7 +227,7 @@ fun ProfileContentTemplate( ) } else { Image( - painter = painterRes(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 3), contentDescription = stringRes(R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = bannerModifier, @@ -392,7 +394,7 @@ private fun FollowingAndFollowerCounts( Row( modifier = drawerSpacing.clickable(onClick = onClick), ) { - val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle() + val followingCount = baseAccountUser.kind3FollowList.flow.collectAsStateWithLifecycle() val followerCount by observeUserFollowerCount(baseAccountUser.userProfile(), accountViewModel) Text( @@ -422,8 +424,6 @@ fun ListContent( accountViewModel: AccountViewModel, nav: INav, ) { - var editMediaServers by remember { mutableStateOf(false) } - var backupDialogOpen by remember { mutableStateOf(false) } Column(modifier) { @@ -455,7 +455,7 @@ fun ListContent( accountViewModel = accountViewModel, onClick = { nav.closeDrawer() - nav.nav(Route.EditRelays()) + nav.nav(Route.EditRelays) }, ) @@ -465,7 +465,7 @@ fun ListContent( tint = MaterialTheme.colorScheme.onBackground, onClick = { nav.closeDrawer() - editMediaServers = true + nav.nav(Route.EditMediaServers) }, ) @@ -480,6 +480,7 @@ fun ListContent( NavigationRow( title = R.string.privacy_options, icon = R.drawable.ic_tor, + iconReference = 1, tint = MaterialTheme.colorScheme.onBackground, nav = nav, route = Route.PrivacyOptions, @@ -523,9 +524,6 @@ fun ListContent( ) } - if (editMediaServers) { - MediaServersListView({ editMediaServers = false }, accountViewModel = accountViewModel, nav = nav) - } if (backupDialogOpen) { AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) } @@ -533,15 +531,15 @@ fun ListContent( @Composable private fun RelayStatus(accountViewModel: AccountViewModel) { - val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) + val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle() RenderRelayStatus(connectedRelaysText) } @Composable -private fun RenderRelayStatus(relayPool: RelayPoolStatus) { +private fun RenderRelayStatus(relayPool: RelayPool.RelayPoolStatus) { val text by - remember(relayPool) { derivedStateOf { "${relayPool.connected}/${relayPool.available}" } } + remember(relayPool) { derivedStateOf { "${relayPool.connected.size}/${relayPool.available.size}" } } val placeHolder = MaterialTheme.colorScheme.placeholderText @@ -567,6 +565,7 @@ private fun RenderRelayStatus(relayPool: RelayPoolStatus) { fun NavigationRow( title: Int, icon: Int, + iconReference: Int, tint: Color, nav: INav, route: Route, @@ -574,6 +573,7 @@ fun NavigationRow( IconRow( title, icon, + iconReference, tint, onClick = { nav.closeDrawer() @@ -622,6 +622,7 @@ fun NavigationRow( fun IconRow( title: Int, icon: Int, + iconReference: Int, tint: Color, onClick: () -> Unit, ) { @@ -638,7 +639,7 @@ fun IconRow( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterRes(icon), + painter = painterRes(icon, iconReference), contentDescription = stringRes(title), modifier = Size22Modifier, tint = tint, @@ -724,8 +725,8 @@ fun IconRowRelays( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterRes(R.drawable.relays), - null, + painter = painterRes(R.drawable.relays, 4), + contentDescription = stringRes(R.string.relay_setup), modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurface, ) @@ -781,7 +782,7 @@ fun BottomContent( nav.closeDrawer() }, ) { - append("v12" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) + append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) } } } @@ -800,7 +801,7 @@ fun BottomContent( }, ) { Icon( - painter = painterRes(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 2), contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt new file mode 100644 index 0000000000..8e3324ae6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import kotlin.reflect.KClass + +@Stable +object EmptyNav : INav { + override val scope: CoroutineScope get() = TODO("Not yet implemented") + override val drawerState = DrawerState(DrawerValue.Closed) + + override fun closeDrawer() = runBlocking { drawerState.close() } + + override fun openDrawer() = runBlocking { drawerState.open() } + + override fun nav(route: Route) {} + + override fun nav(computeRoute: suspend () -> Route?) {} + + override fun newStack(route: Route) {} + + override fun popBack() {} + + override fun popUpTo( + route: Route, + klass: KClass, + ) {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt new file mode 100644 index 0000000000..aefcb060cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.material3.DrawerState +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.CoroutineScope +import kotlin.reflect.KClass + +@Stable +interface INav { + val scope: CoroutineScope + val drawerState: DrawerState + + fun closeDrawer() + + fun openDrawer() + + fun nav(route: Route) + + fun nav(computeRoute: suspend () -> Route?) + + fun newStack(route: Route) + + fun popBack() + + fun popUpTo( + route: Route, + klass: KClass, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 868cc8425b..fa3b50e994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -18,64 +18,23 @@ * 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.navigation +package com.vitorpamplona.amethyst.ui.navigation.navs import android.annotation.SuppressLint import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue -import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlin.reflect.KClass -@Composable -fun rememberNav(): Nav { - val navController = rememberNavController() - val scope = rememberCoroutineScope() - - return remember(navController, scope) { - Nav(navController, scope) - } -} - -@Composable -fun rememberExtendedNav( - nav: INav, - onClose: () -> Unit, -): INav = nav.onNavigate(onClose) - -@Stable -interface INav { - val drawerState: DrawerState - - fun nav(route: Route) - - fun nav(computeRoute: suspend () -> Route?) - - fun newStack(route: Route) - - fun popBack() - - fun popUpTo( - route: Route, - klass: KClass, - ) - - fun closeDrawer() - - fun openDrawer() -} - @Stable class Nav( val controller: NavHostController, - val scope: CoroutineScope, + override val scope: CoroutineScope, ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) @@ -124,83 +83,12 @@ class Nav( @SuppressLint("RestrictedApi") override fun popUpTo( route: Route, - upToClass: KClass, + klass: KClass, ) { scope.launch { controller.navigate(route) { - popUpTo(upToClass) { inclusive = true } + popUpTo(klass) { inclusive = true } } } } } - -@Stable -object EmptyNav : INav { - override val drawerState = DrawerState(DrawerValue.Closed) - - override fun closeDrawer() { - runBlocking { drawerState.close() } - } - - override fun openDrawer() { - runBlocking { drawerState.open() } - } - - override fun nav(route: Route) {} - - override fun nav(computeRoute: suspend () -> Route?) {} - - override fun newStack(route: Route) {} - - override fun popBack() {} - - override fun popUpTo( - route: Route, - upToClass: KClass, - ) {} -} - -fun INav.onNavigate(runOnNavigate: () -> Unit): INav = ObservableNavigate(this, runOnNavigate) - -class ObservableNavigate( - val nav: INav, - val onNavigate: () -> Unit, -) : INav { - override val drawerState: DrawerState = nav.drawerState - - override fun closeDrawer() { - nav.closeDrawer() - } - - override fun openDrawer() { - nav.openDrawer() - } - - override fun nav(route: Route) { - onNavigate() - nav.nav(route) - } - - override fun nav(computeRoute: suspend () -> Route?) { - onNavigate() - nav.nav(computeRoute) - } - - override fun newStack(route: Route) { - onNavigate() - nav.newStack(route) - } - - override fun popBack() { - onNavigate() - nav.popBack() - } - - override fun popUpTo( - route: Route, - upToClass: KClass, - ) { - onNavigate() - nav.popUpTo(route, upToClass) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ObservableNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ObservableNav.kt new file mode 100644 index 0000000000..c1ecb01995 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ObservableNav.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.material3.DrawerState +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +class ObservableNav( + val sourceNav: INav, + override val scope: CoroutineScope, + val onBeforeNavigate: () -> Unit, +) : INav { + override val drawerState: DrawerState = sourceNav.drawerState + + override fun closeDrawer() { + sourceNav.closeDrawer() + } + + override fun openDrawer() { + sourceNav.openDrawer() + } + + override fun nav(route: Route) { + scope.launch { + onBeforeNavigate() + } + sourceNav.nav(route) + } + + override fun nav(computeRoute: suspend () -> Route?) { + scope.launch { + onBeforeNavigate() + } + sourceNav.nav(computeRoute) + } + + override fun newStack(route: Route) { + scope.launch { + onBeforeNavigate() + } + sourceNav.newStack(route) + } + + override fun popBack() { + scope.launch { + onBeforeNavigate() + } + sourceNav.popBack() + } + + override fun popUpTo( + route: Route, + upToClass: KClass, + ) { + scope.launch { + onBeforeNavigate() + } + sourceNav.popUpTo(route, upToClass) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt index f6aba1cb2e..218f5ae927 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt @@ -18,21 +18,30 @@ * 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 +package com.vitorpamplona.amethyst.ui.navigation.navs import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.compose.rememberNavController @Composable -fun RelaySubscriptionsCoordinatorSubscription(accountViewModel: AccountViewModel) = RelaySubscriptionsCoordinatorSubscription(accountViewModel.dataSources()) +fun rememberNav(): Nav { + val navController = rememberNavController() + val scope = rememberCoroutineScope() -@Composable -fun RelaySubscriptionsCoordinatorSubscription(dataSource: RelaySubscriptionsCoordinator) { - DisposableEffect(Unit) { - dataSource.start() - onDispose { - dataSource.stop() - } + return remember(navController, scope) { + Nav(navController, scope) + } +} + +@Composable +fun rememberExtendedNav( + nav: INav, + onBeforeNavigate: () -> Unit, +): INav { + val scope = rememberCoroutineScope() + return remember(nav, scope) { + ObservableNav(nav, scope, onBeforeNavigate) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index cc3515cb39..a51df64409 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -18,14 +18,18 @@ * 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.navigation +package com.vitorpamplona.amethyst.ui.navigation.routes -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.EphemeralChatChannel +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.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -39,6 +43,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped @@ -46,36 +52,36 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun routeFor( note: Note, - loggedIn: User, + loggedIn: Account, ): Route? { - val noteEvent = note.event ?: return Route.Note(note.idHex) + val noteEvent = note.event ?: return Route.EventRedirect(note.idHex) return routeFor(noteEvent, loggedIn) } fun routeFor( noteEvent: Event, - loggedIn: User, + loggedIn: Account, ): Route? { if (noteEvent is DraftEvent) { - val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex) + val innerEvent = loggedIn.draftsDecryptionCache.preCachedDraft(noteEvent) if (innerEvent is IsInPublicChatChannel) { innerEvent.channelId()?.let { - return Route.Channel(it) + return Route.PublicChatChannel(it) } } else if (innerEvent is LiveActivitiesEvent) { - innerEvent.aTag().toTag().let { - return Route.Channel(it) + innerEvent.address().let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) } } else if (innerEvent is LiveActivitiesChatMessageEvent) { - innerEvent.activity()?.toTag()?.let { - return Route.Channel(it) + innerEvent.activityAddress()?.let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) } } else if (innerEvent is ChatroomKeyable) { - val room = innerEvent.chatroomKey(loggedIn.pubkeyHex) - loggedIn.createChatroom(room) - return Route.Room(room.hashCode()) + val room = innerEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) + loggedIn.chatroomList.getOrCreatePrivateChatroom(room) + return Route.Room(room) } else if (innerEvent is AddressableEvent) { return Route.Note(noteEvent.aTag().toTag()) } else { @@ -85,24 +91,32 @@ fun routeFor( return Route.ContentDiscovery(noteEvent.id) } else if (noteEvent is IsInPublicChatChannel) { noteEvent.channelId()?.let { - return Route.Channel(it) + return Route.PublicChatChannel(it) } } else if (noteEvent is ChannelCreateEvent) { - return Route.Channel(noteEvent.id) + return Route.PublicChatChannel(noteEvent.id) } else if (noteEvent is LiveActivitiesEvent) { - noteEvent.aTag().toTag().let { - return Route.Channel(it) + noteEvent.address().let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) } } else if (noteEvent is LiveActivitiesChatMessageEvent) { - noteEvent.activity()?.toTag()?.let { - return Route.Channel(it) + noteEvent.activityAddress()?.let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) } } else if (noteEvent is ChatroomKeyable) { - val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) - loggedIn.createChatroom(room) - return Route.Room(room.hashCode()) + val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) + loggedIn.chatroomList.getOrCreatePrivateChatroom(room) + return Route.Room(room) } else if (noteEvent is CommunityDefinitionEvent) { - return Route.Community(noteEvent.aTag().toTag()) + return Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } else if (noteEvent is GiftWrapEvent) { + noteEvent.innerEventId?.let { + return routeFor(LocalCache.getOrCreateNote(it), loggedIn) + } + } else if (noteEvent is SealedRumorEvent) { + noteEvent.innerEventId?.let { + return routeFor(LocalCache.getOrCreateNote(it), loggedIn) + } } else if (noteEvent is AddressableEvent) { return Route.Note(noteEvent.aTag().toTag()) } else { @@ -147,18 +161,18 @@ fun routeToMessage( replyId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, -): Route = routeToMessage(room, draftMessage, replyId, draftId, accountViewModel.userProfile()) +): Route = routeToMessage(room, draftMessage, replyId, draftId, accountViewModel.account) fun routeToMessage( room: ChatroomKey, - draftMessage: String?, + draftMessage: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, - fromUser: User, + account: Account, ): Route { - fromUser.createChatroom(room) + account.chatroomList.getOrCreatePrivateChatroom(room) - return Route.Room(room.hashCode(), draftMessage, replyId, draftId) + return Route.Room(room, draftMessage, replyId, draftId) } fun routeToMessage( @@ -169,14 +183,13 @@ fun routeToMessage( accountViewModel: AccountViewModel, ): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel) -fun routeFor(note: Channel): Route = - if (note is EphemeralChatChannel) { - Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl) - } else { - Route.Channel(note.idHex) - } +fun routeFor(note: EphemeralChatChannel): Route = Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl.url) -fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl) +fun routeFor(note: PublicChatChannel): Route = Route.PublicChatChannel(note.idHex) + +fun routeFor(note: LiveActivitiesChannel): Route = Route.LiveActivityChannel(note.address.kind, note.address.pubKeyHex, note.address.dTag) + +fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl.url) fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) @@ -184,26 +197,27 @@ fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { R fun routeReplyTo( note: Note, - asUser: User, + account: Account, ): Route? { val noteEvent = note.event return when (noteEvent) { + is PublicMessageEvent -> Route.NewPublicMessage(noteEvent.groupKeySet() - account.userProfile().pubkeyHex) is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex) is PrivateDmEvent -> routeToMessage( - room = noteEvent.chatroomKey(asUser.pubkeyHex), + room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), draftMessage = null, replyId = noteEvent.id, draftId = null, - fromUser = asUser, + account = account, ) is ChatroomKeyable -> routeToMessage( - room = noteEvent.chatroomKey(asUser.pubkeyHex), + room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), draftMessage = null, replyId = noteEvent.id, draftId = null, - fromUser = asUser, + account = account, ) is CommentEvent -> { if (noteEvent.isGeohashedScoped()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 7be1576e3f..0dbc6c0a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -18,27 +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.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.routes -import androidx.compose.foundation.layout.size -import androidx.compose.ui.Modifier import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size23dp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.serialization.Serializable -class BottomBarRoute( - val route: Route, - val icon: Int, - val contentDescriptor: Int = R.string.route, - val notifSize: Modifier = Modifier.size(Size23dp), - val iconSize: Modifier = Modifier.size(Size20dp), -) - sealed class Route { @Serializable object Home : Route() @@ -66,9 +54,9 @@ sealed class Route { @Serializable object EditProfile : Route() - @Serializable data class EditRelays( - val toAdd: String? = null, - ) : Route() + @Serializable object EditRelays : Route() + + @Serializable object EditMediaServers : Route() @Serializable data class Nip47NWCSetup( val nip47: String? = null, @@ -87,21 +75,37 @@ sealed class Route { ) : Route() @Serializable data class Hashtag( - val id: String, + val hashtag: String, ) : Route() @Serializable data class Geohash( - val id: String, + val geohash: String, ) : Route() @Serializable data class Community( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() + + @Serializable data class PublicChatChannel( val id: String, ) : Route() - @Serializable data class Channel( + @Serializable data class LiveActivityChannel( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() + + @Serializable data class EphemeralChatChannel( val id: String, ) : Route() + @Serializable data class RelayInfo( + val url: String, + ) : Route() + @Serializable data class EphemeralChat( val id: String, val relayUrl: String, @@ -119,11 +123,30 @@ sealed class Route { ) : Route() @Serializable data class Room( - val id: Int, + val id: String, val message: String? = null, val replyId: HexKey? = null, val draftId: HexKey? = null, - ) : Route() + ) : Route() { + constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null) : this( + id = key.users.joinToString(","), + message = message, + replyId = replyId, + draftId = draftId, + ) + + fun toKey(): ChatroomKey = ChatroomKey(id.split(",").toSet()) + } + + @Serializable data class NewPublicMessage( + val to: String, + ) : Route() { + constructor(users: Set) : this( + to = users.joinToString(","), + ) + + fun toKey(): Set = to.split(",").toSet() + } @Serializable data class RoomByAuthor( val id: String, @@ -210,13 +233,17 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() - dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() @@ -230,3 +257,24 @@ fun getRouteWithArguments(navController: NavHostController): Route? { } } } + +fun isSameRoute( + currentRoute: Route?, + newRoute: Route, +): Boolean { + if (currentRoute == null) return false + + if (currentRoute == newRoute) { + return true + } + + if (newRoute is Route.EventRedirect) { + return when (currentRoute) { + is Route.Note -> newRoute.id == currentRoute.id + is Route.PublicChatChannel -> newRoute.id == currentRoute.id + else -> false + } + } + + return false +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt new file mode 100644 index 0000000000..cb9cea8e5d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.topbars + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionTopBar( + postRes: Int, + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) { + ShorterTopAppBar( + title = { + if (titleRes != null) { + Text( + text = stringRes(titleRes), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + }, + navigationIcon = { + CloseButton( + modifier = HalfHorzPadding, + onPress = onCancel, + ) + }, + actions = { + Button( + modifier = HalfHorzPadding, + enabled = isActive(), + onClick = onPost, + ) { + Text(text = stringRes(postRes)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.post, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SavingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.save, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.create, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AmethystClickableIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AmethystClickableIcon.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AmethystClickableIcon.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AmethystClickableIcon.kt index 656bdd7b11..0d1584ff89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AmethystClickableIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AmethystClickableIcon.kt @@ -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.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 44640dd41c..c031d040a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import android.Manifest import androidx.compose.foundation.clickable @@ -71,8 +71,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -263,7 +263,7 @@ fun RenderOption( if (noteEvent is PeopleListEvent) { noteEvent.nameOrTitle() ?: option.note.dTag() } else if (noteEvent is FollowListEvent) { - noteEvent.nameOrTitle() ?: option.note.dTag() + noteEvent.title() ?: option.note.dTag() } else { option.note.dTag() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBar.kt new file mode 100644 index 0000000000..8f8645eca1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBar.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.topbars + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val TopBarSize = 50.dp + +@ExperimentalMaterial3Api +@Composable +fun ShorterTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + expandedHeight: Dp = TopBarSize, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null, +) = TopAppBar( + title, + modifier, + navigationIcon, + actions, + expandedHeight, + windowInsets, + colors, + scrollBehavior, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarExtensibleWithBackButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index 3590b230ff..9b652d99cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -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.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -31,7 +31,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,8 +68,7 @@ fun MyExtensibleTopAppBar( Column( Modifier.clickable { expanded.value = !expanded.value }, ) { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), + ShorterTopAppBar( title = { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarWithBackButton.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarWithBackButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarWithBackButton.kt index 9e23157039..2a0b46ba7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarWithBackButton.kt @@ -18,14 +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.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @OptIn(ExperimentalMaterial3Api::class) @@ -34,17 +33,18 @@ fun TopBarWithBackButton( caption: String, popBack: () -> Unit, ) { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), - title = { Text(caption) }, + ShorterTopAppBar( + title = { + Text( + text = caption, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, navigationIcon = { - IconButton( - onClick = popBack, - modifier = Modifier, - ) { + IconButton(popBack) { ArrowBackIcon() } }, - actions = {}, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index b589ef8464..f5bfc66605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -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.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -26,43 +26,32 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.SearchIcon -import com.vitorpamplona.amethyst.ui.screen.FeedDefinition -import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -@Composable -fun MainTopBar( - accountViewModel: AccountViewModel, - nav: INav, -) { - GenericMainTopBar(accountViewModel, nav) { AmethystClickableIcon() } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun GenericMainTopBar( +fun UserDrawerSearchTopBar( accountViewModel: AccountViewModel, nav: INav, content: @Composable () -> Unit, ) { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), + ShorterTopAppBar( title = { Column( modifier = Modifier.fillMaxWidth(), @@ -102,39 +91,3 @@ private fun LoggedInUserPictureDrawer( ) } } - -@Composable -fun FollowListWithRoutes( - followListsModel: FollowListState, - listName: String, - accountViewModel: AccountViewModel, - onChange: (FeedDefinition) -> Unit, -) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() - - FeedFilterSpinner( - placeholderCode = listName, - explainer = stringRes(R.string.select_list_to_filter), - options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, - accountViewModel = accountViewModel, - ) -} - -@Composable -fun FollowListWithoutRoutes( - followListsModel: FollowListState, - listName: String, - accountViewModel: AccountViewModel, - onChange: (FeedDefinition) -> Unit, -) { - val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() - - FeedFilterSpinner( - placeholderCode = listName, - explainer = stringRes(R.string.select_list_to_filter), - options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, - accountViewModel = accountViewModel, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 6d176cdda8..717a4439c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -43,8 +43,8 @@ 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.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -84,7 +84,7 @@ fun BadgeCompose( onClick = { routeFor( note, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 3afde97560..35497746eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -37,8 +37,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt index 7d35a08634..bdcfa1d943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt index 4ecc152d94..81de3a9ea1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt @@ -20,26 +20,23 @@ */ package com.vitorpamplona.amethyst.ui.note -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder @Composable fun DisplayAuthorBanner( note: Note, accountViewModel: AccountViewModel, + modifier: Modifier = SimpleImageBorder, ) { WatchAuthor(note, accountViewModel) { BannerImage( it, - Modifier - .fillMaxSize() - .clip(QuoteBorder), + modifier, accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt index 5f0f3292d7..cc9fb379f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt @@ -81,7 +81,7 @@ fun ErrorMessageDialog( onClickStartMessage?.let { TextButton(onClick = onClickStartMessage) { Icon( - painter = painterRes(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 3), contentDescription = null, ) Spacer(StdHorzSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt index 61e10c3d28..25f1c7e159 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt @@ -36,8 +36,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size25dp import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index abd01796a8..6bd827c590 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -31,16 +31,15 @@ import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DownloadForOffline -import androidx.compose.material.icons.filled.Downloading import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -49,7 +48,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.vitorpamplona.amethyst.R @@ -64,7 +62,6 @@ import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Reposted import com.vitorpamplona.amethyst.commons.icons.Search import com.vitorpamplona.amethyst.commons.icons.Zap -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size19Modifier @@ -108,16 +105,6 @@ fun ArrowBackIcon(tint: Color = MaterialTheme.colorScheme.grayText) { ) } -@Composable -fun MessageIcon(modifier: Modifier) { - Icon( - painter = painterRes(R.drawable.ic_dm), - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.primary, - ) -} - @Composable fun DownloadForOfflineIcon( iconSize: Dp, @@ -131,26 +118,6 @@ fun DownloadForOfflineIcon( ) } -@Composable -fun HashCheckIcon(iconSize: Dp) { - Icon( - painter = painterRes(R.drawable.original), - contentDescription = stringRes(id = R.string.hash_verification_passed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Unspecified, - ) -} - -@Composable -fun HashCheckFailedIcon(iconSize: Dp) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringRes(id = R.string.hash_verification_failed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Red, - ) -} - @Composable fun LikedIcon( modifier: Modifier, @@ -348,6 +315,19 @@ fun ExpandMoreIcon( ) } +@Composable +fun VoiceReplyIcon( + iconSizeModifier: Modifier, + tint: Color, +) { + Icon( + imageVector = Icons.Outlined.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = tint, + modifier = iconSizeModifier, + ) +} + @Composable fun CommentIcon( iconSizeModifier: Modifier, @@ -361,26 +341,6 @@ fun CommentIcon( ) } -@Composable -fun PollIcon() { - Icon( - painter = painterRes(R.drawable.ic_poll), - contentDescription = stringRes(id = R.string.poll), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) -} - -@Composable -fun RegularPostIcon() { - Icon( - painter = painterRes(R.drawable.ic_lists), - contentDescription = stringRes(id = R.string.disable_poll), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) -} - @Composable fun CancelIcon() { Icon( @@ -482,59 +442,3 @@ fun VerticalDotsIcon() { tint = MaterialTheme.colorScheme.placeholderText, ) } - -@Composable -fun NIP05CheckingIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Downloading, - contentDescription = stringRes(id = R.string.nip05_checking), - modifier = modifier, - tint = Color.Yellow, - ) -} - -@Composable -fun NIP05VerifiedIcon(modifier: Modifier) { - Icon( - painter = painterRes(R.drawable.nip_05), - contentDescription = stringRes(id = R.string.nip05_verified), - modifier = modifier, - tint = Color.Unspecified, - ) -} - -@Composable -fun NIP05FailedVerification(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringRes(id = R.string.nip05_failed), - modifier = modifier, - tint = Color.Red, - ) -} - -@Composable -fun IncognitoIconOn( - modifier: Modifier, - tint: Color, -) { - Icon( - painter = painterResource(id = R.drawable.incognito), - contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), - modifier = modifier, - tint = tint, - ) -} - -@Composable -fun IncognitoIconOff( - modifier: Modifier, - tint: Color, -) { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message), - modifier = modifier, - tint = tint, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 874d2597ec..570c04cb04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -27,14 +27,16 @@ import androidx.compose.runtime.ProduceStateScope import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.components.GenericLoadable @@ -42,10 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext @Composable @@ -88,50 +87,19 @@ fun LoadDecryptedContentOrNull( inner(decryptedContent) } -@Composable -fun LoadAddressableNote( - aTagHex: String, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, -) { - var note by - remember(aTagHex) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) - } - - if (note == null) { - LaunchedEffect(key1 = aTagHex) { - accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> - if (newNote != note) { - note = newNote - } - } - } - } - - content(note) -} - @Composable fun LoadAddressableNote( address: Address, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit, ) { - var note by - remember(address) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(address.toValue())) - } - - if (note == null) { - LaunchedEffect(key1 = address) { - val newNote = - withContext(Dispatchers.IO) { - accountViewModel.getOrCreateAddressableNote(address) - } - if (note != newNote) { - note = newNote - } + val note by produceState( + accountViewModel.getAddressableNoteIfExists(address), + address, + ) { + val newNote = accountViewModel.getOrCreateAddressableNote(address) + if (newNote != value) { + value = newNote } } @@ -185,36 +153,42 @@ fun LoadOts( } @Composable -fun LoadChannel( - baseChannelHex: String, +fun LoadPublicChatChannel( + id: String, accountViewModel: AccountViewModel, - content: @Composable (Channel) -> Unit, + content: @Composable (PublicChatChannel) -> Unit, ) { - var channel by - remember(baseChannelHex) { - mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + val channel = + produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreatePublicChatChannel(id) } - if (channel == null) { - LaunchedEffect(key1 = baseChannelHex) { - accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> - launch(Dispatchers.Main) { channel = newChannel } - } - } - } - - channel?.let { content(it) } + channel.value?.let { content(it) } } @Composable -fun LoadChannel( +fun LoadEphemeralChatChannel( id: RoomId, accountViewModel: AccountViewModel, content: @Composable (EphemeralChatChannel) -> Unit, ) { - var channel = - produceStateIfNotNull(accountViewModel.getChannelIfExists(id) as? EphemeralChatChannel, id) { - value = accountViewModel.checkGetOrCreateChannel(id) as? EphemeralChatChannel + val channel = + produceStateIfNotNull(accountViewModel.getEphemeralChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreateEphemeralChatChannel(id) + } + + channel.value?.let { content(it) } +} + +@Composable +fun LoadLiveActivityChannel( + id: Address, + accountViewModel: AccountViewModel, + content: @Composable (LiveActivitiesChannel) -> Unit, +) { + val channel = + produceStateIfNotNull(accountViewModel.getLiveActivityChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreateLiveActivityChannel(id) } channel.value?.let { content(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index be83c4641f..70abe8becb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,9 +39,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.MessageSetCard import com.vitorpamplona.amethyst.ui.theme.StdStartPadding @@ -81,7 +85,7 @@ fun MessageSetCompose( scope.launch { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } } }, @@ -116,6 +120,11 @@ fun MessageSetCompose( @Composable fun MessageIconBox() { Box(Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp)) { - MessageIcon(Modifier.size(16.dp).align(Alignment.TopEnd)) + Icon( + painter = painterRes(R.drawable.ic_dm, 4), + null, + modifier = Modifier.size(16.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.primary, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 0fd1c2e5d6..6caa3dff45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -74,9 +74,9 @@ import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap @@ -134,7 +134,7 @@ fun MultiSetCompose( .background(backgroundColor.value) .combinedClickable( onClick = { - scope.launch { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } } + scope.launch { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } } }, onLongClick = { popupExpanded.value = true }, ).padding( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index aca67bf2fd..e55fbd8800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Downloading +import androidx.compose.material.icons.filled.Report import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -41,11 +43,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Tunestr import com.vitorpamplona.amethyst.model.AddressableNote @@ -56,14 +60,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNip05 import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon -import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification -import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size15Modifier @@ -324,7 +327,7 @@ fun DisplayStatusInner( onClick = { routeFor( note, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { @@ -346,7 +349,7 @@ fun DisplayStatusInner( onClick = { routeFor( it, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { @@ -389,7 +392,7 @@ fun DisplayNIP05( ) } - NIP05VerifiedSymbol(nip05Verified, NIP05IconSize, accountViewModel) + NIP05VerifiedSymbol(nip05Verified, 1, NIP05IconSize, accountViewModel) ClickableTextPrimary( text = domain, @@ -404,14 +407,33 @@ fun DisplayNIP05( @Composable private fun NIP05VerifiedSymbol( nip05Verified: MutableState, + compositionSizeReference: Int, modifier: Modifier, accountViewModel: AccountViewModel, ) { CrossfadeIfEnabled(targetState = nip05Verified.value, accountViewModel = accountViewModel) { when (it) { - null -> NIP05CheckingIcon(modifier = modifier) - true -> NIP05VerifiedIcon(modifier = modifier) - false -> NIP05FailedVerification(modifier = modifier) + null -> + Icon( + imageVector = Icons.Default.Downloading, + contentDescription = stringRes(id = R.string.nip05_checking), + modifier = modifier, + tint = Color.Yellow, + ) + true -> + Icon( + painter = painterRes(R.drawable.nip_05, compositionSizeReference), + contentDescription = stringRes(id = R.string.nip05_verified), + modifier = modifier, + tint = Color.Unspecified, + ) + false -> + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringRes(id = R.string.nip05_failed), + modifier = modifier, + tint = Color.Red, + ) } } } @@ -427,7 +449,7 @@ fun DisplayNip05ProfileStatus( if (nip05.split("@").size <= 2) { val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) Row(verticalAlignment = Alignment.CenterVertically) { - NIP05VerifiedSymbol(nip05Verified, Size16Modifier, accountViewModel) + NIP05VerifiedSymbol(nip05Verified, 2, Size16Modifier, accountViewModel) var domainPadStart = 5.dp val (user, domain) = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9595dc5763..7a887efe33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -52,9 +52,9 @@ import androidx.compose.ui.unit.dp 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.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent @@ -62,8 +62,8 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus @@ -78,12 +78,17 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.EmptyState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay @@ -114,16 +119,18 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderReaction import com.vitorpamplona.amethyst.ui.note.types.RenderReport import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment +import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.RenderChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP @@ -155,6 +162,7 @@ import com.vitorpamplona.quartz.experimental.forks.isAFork import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope @@ -181,10 +189,15 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -204,6 +217,7 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import kotlinx.coroutines.delay @Composable @@ -268,17 +282,24 @@ fun AcceptableNote( nav: INav, ) { if (isQuotedNote || isBoostedNote) { - when (baseNote.event) { - is ChannelCreateEvent, - is ChannelMetadataEvent, - -> - RenderChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, + val noteEvent = baseNote.event + when (noteEvent) { + is ChannelCreateEvent -> + RenderPublicChatChannelHeader( + channelId = noteEvent.id, sendToChannel = true, accountViewModel = accountViewModel, nav = nav, ) + is ChannelMetadataEvent -> + noteEvent.channelId()?.let { + RenderPublicChatChannelHeader( + channelId = it, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { RenderCommunity( @@ -308,17 +329,23 @@ fun AcceptableNote( } } } else { - when (baseNote.event) { - is ChannelCreateEvent, - is ChannelMetadataEvent, - -> - RenderChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, + when (val noteEvent = baseNote.event) { + is ChannelCreateEvent -> + RenderPublicChatChannelHeader( + channelId = noteEvent.id, sendToChannel = true, accountViewModel = accountViewModel, nav = nav, ) + is ChannelMetadataEvent -> + noteEvent.channelId()?.let { + RenderPublicChatChannelHeader( + channelId = it, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { RenderCommunity( @@ -457,7 +484,7 @@ fun ClickableNote( } else { baseNote } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav.nav(it) } + routeFor(redirectToNote, accountViewModel.account)?.let { nav.nav(it) } }, onLongClick = showPopup, ).background(backgroundColor.value) @@ -635,6 +662,11 @@ private fun RenderNoteRow( is ChatMessageRelayListEvent -> DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) is AdvertisedRelayListEvent -> DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) is SearchRelayListEvent -> DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) + is BlockedRelayListEvent -> DisplayBlockedRelayList(baseNote, backgroundColor, accountViewModel, nav) + is TrustedRelayListEvent -> DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) + is IndexerRelayListEvent -> DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) + is ProxyRelayListEvent -> DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) + is BroadcastRelayListEvent -> DisplayBroadcastRelayList(baseNote, backgroundColor, accountViewModel, nav) is PinListEvent -> RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) is EmojiPackEvent -> RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) is LiveActivitiesEvent -> RenderLiveActivityEvent(baseNote, accountViewModel, nav) @@ -762,7 +794,7 @@ private fun RenderNoteRow( is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav) - + is BaseVoiceEvent -> RenderVoiceTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) is CommunityPostApprovalEvent -> { RenderPostApproval( @@ -837,7 +869,16 @@ private fun RenderNoteRow( accountViewModel, nav, ) - + is PublicMessageEvent -> + RenderPublicMessage( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) else -> { RenderTextEvent( baseNote, @@ -977,7 +1018,7 @@ fun SecondUserInfoRow( val geo = remember(noteEvent) { noteEvent.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } @@ -1166,7 +1207,8 @@ private fun RenderAuthorImages( nav: INav, accountViewModel: AccountViewModel, ) { - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + val noteEvent = baseNote.event + if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { val baseRepost = baseNote.replyTo?.lastOrNull() if (baseRepost != null) { RepostNoteAuthorPicture(baseNote, baseRepost, accountViewModel, nav) @@ -1177,10 +1219,10 @@ private fun RenderAuthorImages( NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) } - if (baseNote.event is ChannelMessageEvent) { - val baseChannelHex = remember(baseNote) { baseNote.channelHex() } + if (noteEvent is ChannelMessageEvent) { + val baseChannelHex = noteEvent.channelId() if (baseChannelHex != null) { - LoadChannel(baseChannelHex, accountViewModel) { channel -> + LoadPublicChatChannel(baseChannelHex, accountViewModel) { channel -> ChannelNotePicture( channel, accountViewModel, @@ -1192,7 +1234,7 @@ private fun RenderAuthorImages( @Composable private fun ChannelNotePicture( - baseChannel: Channel, + baseChannel: PublicChatChannel, accountViewModel: AccountViewModel, ) { val model by observeChannelPicture(baseChannel, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 12db18fcba..84339a6af8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -82,19 +82,19 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -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.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.LightRedColor 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.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlinx.coroutines.launch @@ -480,7 +480,7 @@ private fun BlockAlertDialog( buttonText = stringRes(R.string.quick_action_block_dialog_btn), buttonColors = ButtonDefaults.buttonColors( - containerColor = WarningColor, + containerColor = LightRedColor, contentColor = Color.White, ), onClickDoOnce = { @@ -528,6 +528,7 @@ fun QuickActionAlertDialog( title: String, textContent: String, buttonIconResource: Int, + buttonIconReference: Int, buttonText: String, buttonColors: ButtonColors = ButtonDefaults.buttonColors(), onClickDoOnce: () -> Unit, @@ -539,7 +540,7 @@ fun QuickActionAlertDialog( textContent = textContent, icon = { Icon( - painter = painterRes(buttonIconResource), + painter = painterRes(buttonIconResource, buttonIconReference), contentDescription = null, ) }, @@ -626,6 +627,7 @@ fun QuickActionAlertDialogOneButton( title: String, textContent: String, buttonIconResource: Int, + buttonIconReference: Int, buttonText: String, buttonColors: ButtonColors = ButtonDefaults.buttonColors(), onClickDoOnce: () -> Unit, @@ -636,7 +638,7 @@ fun QuickActionAlertDialogOneButton( textContent = textContent, icon = { Icon( - painter = painterRes(buttonIconResource), + painter = painterRes(buttonIconResource, buttonIconReference), contentDescription = null, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index bca63658b3..c448e01791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -78,9 +78,9 @@ 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 -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockVitorAccountViewModel @@ -243,7 +243,8 @@ fun PollNote( ) { val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel${baseNote.idHex}") - pollViewModel.load(accountViewModel.account, baseNote) + pollViewModel.init(accountViewModel.account) + pollViewModel.load(baseNote) PollNote( baseNote = baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 525492b300..3692520687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -50,7 +50,7 @@ data class PollOption( @Stable class PollNoteViewModel : ViewModel() { - private var account: Account? = null + private lateinit var account: Account private var pollNote: Note? = null private var pollEvent: PollNoteEvent? = null @@ -69,12 +69,12 @@ class PollNoteViewModel : ViewModel() { var canZap = mutableStateOf(false) var tallies: List = emptyList() - fun load( - acc: Account, - note: Note?, - ) { - if (acc != account || pollNote != note) { - account = acc + fun init(acc: Account) { + account = acc + } + + fun load(note: Note?) { + if (pollNote != note) { pollNote = note pollEvent = pollNote?.event as PollNoteEvent pollOptions = pollEvent?.pollOptions() @@ -221,7 +221,10 @@ class PollNoteViewModel : ViewModel() { ): Boolean = pollNote!!.zaps.any { val zapEvent = it.value?.event as? LnZapEvent - val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap() + val privateZapAuthor = + (it.key.event as? LnZapRequestEvent)?.let { + account.privateZapsDecryptionCache.cachedPrivateZap(it) + } zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index d9e29088bf..9e18f7e2f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.note import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -fun ByteArray.toShortenHex(): String = toHexKey().toShortenHex() +fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay() -fun String.toShortenHex(): String { +fun String.toShortDisplay(): String { if (length <= 16) return this - return replaceRange(8, length - 8, ":") + return replaceRange(8, length - 8, "…") } -fun HexKey.toDisplayHexKey(): String = this.toShortenHex() +fun HexKey.toDisplayHexKey(): String = this.toShortDisplay() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index aa8ca82855..485d665cd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -93,6 +93,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.FeatureSetType @@ -107,14 +108,16 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo 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.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeReplyTo +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -155,6 +158,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -350,10 +354,9 @@ fun RenderZapRaiser( LaunchedEffect(key1 = zapsState) { zapsState?.note?.let { - accountViewModel.calculateZapraiser(baseNote) { newStatus -> - if (zapraiserStatus != newStatus) { - zapraiserStatus = newStatus - } + val newStatus = accountViewModel.calculateZapraiser(baseNote) + if (zapraiserStatus != newStatus) { + zapraiserStatus = newStatus } } } @@ -578,8 +581,37 @@ private fun ReplyReactionWithDialog( accountViewModel: AccountViewModel, nav: INav, ) { - ReplyReaction(baseNote, grayTint, accountViewModel) { - nav.nav { routeReplyTo(baseNote, accountViewModel.userProfile()) } + if (baseNote.event is BaseVoiceEvent) { + ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel) + } else { + ReplyReaction(baseNote, grayTint, accountViewModel) { + nav.nav { routeReplyTo(baseNote, accountViewModel.account) } + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun ReplyViaVoiceReaction( + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + showCounter: Boolean = true, + iconSizeModifier: Modifier = Size19Modifier, +) { + val context = LocalContext.current + + RecordAudioBox( + modifier = iconSizeModifier, + onRecordTaken = { audio -> + accountViewModel.sendVoiceReply(baseNote, audio, context) + }, + ) { + VoiceReplyIcon(iconSizeModifier, grayTint) + } + + if (showCounter) { + ReplyCounter(baseNote, grayTint, accountViewModel) } } @@ -1156,6 +1188,12 @@ fun ObserveZapIcon( if (!wasZappedByLoggedInUser.value) { val zapsState by observeNoteZaps(baseNote, accountViewModel) + zapsState?.note?.zapPayments?.forEach { + if (it.value == null) { + NWCFinderFilterAssemblerSubscription(it.key, accountViewModel) + } + } + LaunchedEffect(key1 = zapsState) { if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) { accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped -> @@ -1179,14 +1217,19 @@ fun ObserveZapAmountText( val zapsState by observeNoteZaps(baseNote, accountViewModel) if (zapsState?.note?.zapPayments?.isNotEmpty() == true) { + zapsState?.note?.zapPayments?.forEach { + if (it.value == null) { + NWCFinderFilterAssemblerSubscription(it.key, accountViewModel) + } + } + @Suppress("ProduceStateDoesNotAssignValue") val zapAmountTxt by produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) { zapsState?.note?.let { - accountViewModel.calculateZapAmount(it) { newZapAmount -> - if (value != newZapAmount) { - value = newZapAmount - } + val newZapAmount = accountViewModel.calculateZapAmount(it) + if (value != newZapAmount) { + value = newZapAmount } } } @@ -1344,7 +1387,7 @@ fun ReactionChoicePopup( val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } val reactions by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle() - val toRemove = remember { baseNote.reactedBy(accountViewModel.userProfile()).toImmutableSet() } + val toRemove = remember { baseNote.allReactionsByAuthor(accountViewModel.userProfile()).toImmutableSet() } Popup( alignment = Alignment.BottomCenter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 7aa662cf16..45ff9323d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -70,7 +71,7 @@ fun RelayCompose( ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( - relay.url.trim().removePrefix("wss://"), + text = relay.url.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -108,7 +109,8 @@ private fun RelayOptions( onAddRelay: () -> Unit, onRemoveRelay: () -> Unit, ) { - val userState by accountViewModel.normalizedKind3RelaySetFlow.collectAsStateWithLifecycle() + val userState by accountViewModel.account.trustedRelays.flow + .collectAsStateWithLifecycle() if (!userState.contains(relay.url)) { AddRelayButton(onAddRelay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 7f9f126045..27bfd4599a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -52,7 +52,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 617c9bc0c1..f67138bb5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -38,35 +39,39 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size17dp import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface import com.vitorpamplona.amethyst.ui.theme.relayIconModifier import com.vitorpamplona.amethyst.ui.theme.ripple24dp -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.theme.warningColorOnSecondSurface +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable public fun RelayBadgesHorizontal( @@ -117,25 +122,11 @@ fun ChatRelayExpandButton(onClick: () -> Unit) { @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderRelay( - relay: RelayBriefInfoCache.RelayBriefInfo, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { - val relayInfo by loadRelayInfo(relay.url, accountViewModel) - - var openRelayDialog by remember { mutableStateOf(false) } - - if (openRelayDialog) { - relayInfo?.let { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = it, - relayBriefInfo = relay, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + val relayInfo by loadRelayInfo(relay, accountViewModel) val clipboardManager = LocalClipboardManager.current val clickableModifier = @@ -148,34 +139,7 @@ fun RenderRelay( onLongClick = { clipboardManager.setText(AnnotatedString(relay.url)) }, - onClick = { - accountViewModel.retrieveRelayDocument( - relay.url, - onInfo = { - openRelayDialog = true - }, - onError = { url, errorCode, exceptionMessage -> - accountViewModel.toastManager.toast( - R.string.unable_to_download_relay_document, - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - R.string.relay_information_document_error_failed_to_assemble_url - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - R.string.relay_information_document_error_failed_to_reach_server - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - R.string.relay_information_document_error_failed_to_parse_response - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - R.string.relay_information_document_error_failed_with_http - }, - url, - exceptionMessage ?: errorCode.toString(), - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) } @@ -184,8 +148,8 @@ fun RenderRelay( contentAlignment = Alignment.Center, ) { RenderRelayIcon( - displayUrl = relay.displayUrl, - iconUrl = relayInfo?.icon ?: relay.favIcon, + displayUrl = relayInfo.id ?: relay.url, + iconUrl = relayInfo.icon, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, pingInMs = 0, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, @@ -193,6 +157,39 @@ fun RenderRelay( } } +@Preview +@Composable +fun RenderRelayIconPreview() { + ThemeComparisonColumn { + Row { + RenderRelayIcon( + displayUrl = "wss://relay.damus.io", + iconUrl = "wss://relay.damus.io", + loadProfilePicture = true, + pingInMs = 100, + loadRobohash = true, + iconModifier = LargeRelayIconModifier, + ) + RenderRelayIcon( + displayUrl = "wss://relay.damus.io", + iconUrl = "wss://relay.damus.io", + loadProfilePicture = true, + pingInMs = 300, + loadRobohash = true, + iconModifier = LargeRelayIconModifier, + ) + RenderRelayIcon( + displayUrl = "wss://relay.damus.io", + iconUrl = "wss://relay.damus.io", + loadProfilePicture = true, + pingInMs = 500, + loadRobohash = true, + iconModifier = LargeRelayIconModifier, + ) + } + } +} + @Composable fun RenderRelayIcon( displayUrl: String, @@ -202,6 +199,9 @@ fun RenderRelayIcon( pingInMs: Long, iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, ) { + val green = MaterialTheme.colorScheme.allGoodColor + val yellow = MaterialTheme.colorScheme.warningColorOnSecondSurface + val red = MaterialTheme.colorScheme.redColorOnSecondSurface Box( contentAlignment = Alignment.TopEnd, ) { @@ -214,29 +214,35 @@ fun RenderRelayIcon( loadProfilePicture = loadProfilePicture, loadRobohash = loadRobohash, ) + + val textStyle = + remember(pingInMs) { + TextStyle( + color = + if (pingInMs <= 150) { + green + } else if (pingInMs <= 300) { + yellow + } else { + red + }, + ) + } + if (pingInMs > 0) { Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(6.dp)) .background( - Color.Gray, + MaterialTheme.colorScheme.secondaryContainer, ), ) { Text( - modifier = Modifier.padding(4.dp), - style = - TextStyle( - color = - if (pingInMs <= 150) { - Color.Green - } else if (pingInMs <= 300) { - Color.Yellow - } else { - Color.Red - }, - ), + modifier = Modifier.padding(3.dp), + style = textStyle, text = "$pingInMs", + fontSize = 10.sp, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 0c2377b1e3..d3392b4f40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -38,8 +38,8 @@ 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 +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 96acdded67..16060c726f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -51,8 +51,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -71,7 +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.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder @@ -82,34 +81,37 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class UpdateReactionTypeViewModel : ViewModel() { - var account: Account? = null + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account var nextChoice by mutableStateOf(TextFieldValue("")) var reactionSet by mutableStateOf(listOf()) - fun load(myAccount: Account) { - this.account = myAccount - this.reactionSet = myAccount.settings.syncedSettings.reactions.reactionChoices.value + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { + this.reactionSet = account.settings.syncedSettings.reactions.reactionChoices.value } fun toListOfChoices(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } @@ -136,8 +138,7 @@ class UpdateReactionTypeViewModel : ViewModel() { } fun sendPost() { - viewModelScope.launch(Dispatchers.IO) { - account?.changeReactionTypes(reactionSet) + accountViewModel.changeReactionTypes(reactionSet) { nextChoice = TextFieldValue("") } } @@ -146,14 +147,7 @@ class UpdateReactionTypeViewModel : ViewModel() { nextChoice = TextFieldValue("") } - fun hasChanged(): Boolean = - reactionSet != - account - ?.settings - ?.syncedSettings - ?.reactions - ?.reactionChoices - ?.value + fun hasChanged(): Boolean = reactionSet != account.settings.syncedSettings.reactions.reactionChoices.value } @Composable @@ -163,7 +157,11 @@ fun UpdateReactionTypeDialog( nav: INav, ) { val postViewModel: UpdateReactionTypeViewModel = viewModel() - postViewModel.load(accountViewModel.account) + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel) { + postViewModel.load() + } UpdateReactionTypeDialog(postViewModel, onClose, accountViewModel, nav) } @@ -189,28 +187,15 @@ fun UpdateReactionTypeDialog( Scaffold( topBar = { - TopAppBar( - actions = { - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged(), - ) - Spacer(modifier = StdHorzSpacer) + SavingTopBar( + isActive = postViewModel::hasChanged, + onCancel = { + postViewModel.cancel() + onClose() }, - title = {}, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - } + onPost = { + postViewModel.sendPost() + onClose() }, ) }, @@ -386,7 +371,7 @@ private fun EmojiSelector( ) { emptyNote -> emptyNote?.let { usersEmojiList -> val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> - event.emojiPackIds().toImmutableList() + event.emojiPacks().toImmutableList() } collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) } @@ -396,7 +381,7 @@ private fun EmojiSelector( @Composable fun EmojiCollectionGallery( - emojiCollections: ImmutableList, + emojiCollections: ImmutableList
, accountViewModel: AccountViewModel, nav: INav, onClick: ((EmojiUrlTag) -> Unit)? = null, @@ -429,7 +414,7 @@ private fun WatchAndRenderNote( Column( Modifier.fillMaxWidth().clickable { - scope.launch { routeFor(emojiPack, accountViewModel.userProfile())?.let { nav.nav(it) } } + scope.launch { routeFor(emojiPack, accountViewModel.account)?.let { nav.nav(it) } } }, ) { RenderEmojiPack( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 880c5ecd76..907f5bf9a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -60,7 +60,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,17 +74,13 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign 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.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton import com.vitorpamplona.amethyst.ui.painterRes @@ -98,137 +93,12 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey -import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -@Stable -class UpdateZapAmountViewModel : ViewModel() { - var account: Account? = null - - var nextAmount by mutableStateOf(TextFieldValue("")) - var amountSet by mutableStateOf(listOf()) - var walletConnectRelay by mutableStateOf(TextFieldValue("")) - var walletConnectPubkey by mutableStateOf(TextFieldValue("")) - var walletConnectSecret by mutableStateOf(TextFieldValue("")) - var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) - - fun copyFromClipboard(text: String) { - if (text.isBlank()) { - return - } - updateNIP47(text) - } - - fun load(myAccount: Account) { - this.account = myAccount - this.amountSet = myAccount.settings.syncedSettings.zaps.zapAmountChoices.value - this.selectedZapType = myAccount.settings.syncedSettings.zaps.defaultZapType.value - - this.walletConnectPubkey = - myAccount.settings.zapPaymentRequest - ?.pubKeyHex - ?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectRelay = - myAccount.settings.zapPaymentRequest - ?.relayUri - ?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectSecret = - myAccount.settings.zapPaymentRequest - ?.secret - ?.let { TextFieldValue(it) } ?: TextFieldValue("") - } - - fun toListOfAmounts(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - - fun addAmount() { - val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) { - amountSet = amountSet + newValue - } - - nextAmount = TextFieldValue("") - } - - fun removeAmount(amount: Long) { - amountSet = amountSet - amount - } - - fun sendPost() { - val nip47Update = - if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { - val pubkeyHex = - try { - decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - - val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlFormatter.normalize(it) } - val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } - - if (pubkeyHex != null && relayUrl != null) { - Nip47WalletConnect.Nip47URI( - pubkeyHex, - relayUrl, - privKeyHex, - ) - } else { - null - } - } else { - null - } - - viewModelScope.launch(Dispatchers.IO) { - account?.updateZapAmounts(amountSet, selectedZapType, nip47Update) - - nextAmount = TextFieldValue("") - } - } - - fun cancel() { - nextAmount = TextFieldValue("") - } - - fun hasChanged(): Boolean = - ( - selectedZapType != - account - ?.settings - ?.syncedSettings - ?.zaps - ?.defaultZapType - ?.value || - amountSet != - account - ?.settings - ?.syncedSettings - ?.zaps - ?.zapAmountChoices - ?.value || - walletConnectPubkey.text != (account?.settings?.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (account?.settings?.zapPaymentRequest?.relayUri ?: "") || - walletConnectSecret.text != (account?.settings?.zapPaymentRequest?.secret ?: "") - ) - - fun updateNIP47(uri: String) { - val contact = Nip47WalletConnect.parse(uri) - walletConnectPubkey = TextFieldValue(contact.pubKeyHex) - walletConnectRelay = TextFieldValue(contact.relayUri) - walletConnectSecret = TextFieldValue(contact.secret ?: "") - } -} - @Composable fun UpdateZapAmountDialog( onClose: () -> Unit, @@ -236,7 +106,11 @@ fun UpdateZapAmountDialog( accountViewModel: AccountViewModel, ) { val postViewModel: UpdateZapAmountViewModel = viewModel() - postViewModel.load(accountViewModel.account) + postViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel, postViewModel) { + postViewModel.load() + } UpdateZapAmountDialog(postViewModel, onClose, nip47uri, accountViewModel) } @@ -429,8 +303,7 @@ fun UpdateZapAmountContent( ) { TextSpinner( label = stringRes(id = R.string.zap_type_explainer), - placeholder = - zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second, + placeholder = zapTypes.firstOrNull { it.first == accountViewModel.defaultZapType() }?.second ?: zapTypes.firstOrNull()?.second ?: "", options = zapOptions, onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, modifier = Modifier.weight(1f).padding(end = 5.dp), @@ -460,9 +333,9 @@ fun UpdateZapAmountContent( }, ) { Icon( - painter = painterRes(R.drawable.alby), + painter = painterRes(R.drawable.alby, 1), contentDescription = stringRes(id = R.string.accessibility_navigate_to_alby), - modifier = Modifier.size(24.dp), + modifier = Size24Modifier, tint = Color.Unspecified, ) } @@ -475,14 +348,14 @@ fun UpdateZapAmountContent( Icon( Icons.Outlined.ContentPaste, contentDescription = stringRes(id = R.string.paste_from_clipboard), - modifier = Modifier.size(24.dp), + modifier = Size24Modifier, tint = MaterialTheme.colorScheme.primary, ) } IconButton(onClick = { qrScanning = true }) { Icon( - painter = painterRes(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 3), contentDescription = stringRes(id = R.string.accessibility_scan_qr_code), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt new file mode 100644 index 0000000000..7195785f32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.CancellationException + +@Stable +class UpdateZapAmountViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + var nextAmount by mutableStateOf(TextFieldValue("")) + var amountSet by mutableStateOf(listOf()) + var walletConnectRelay by mutableStateOf(TextFieldValue("")) + var walletConnectPubkey by mutableStateOf(TextFieldValue("")) + var walletConnectSecret by mutableStateOf(TextFieldValue("")) + var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) + + fun copyFromClipboard(text: String) { + if (text.isBlank()) { + return + } + updateNIP47(text) + } + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + } + + fun load() { + this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value + this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value + + val nip47 = accountViewModel.account.settings.zapPaymentRequest.value + + this.walletConnectPubkey = nip47?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectRelay = nip47?.relayUri?.url?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectSecret = nip47?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + } + + fun toListOfAmounts(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + + fun addAmount() { + val newValue = nextAmount.text.trim().toLongOrNull() + if (newValue != null) { + amountSet = amountSet + newValue + } + + nextAmount = TextFieldValue("") + } + + fun removeAmount(amount: Long) { + amountSet = amountSet - amount + } + + fun sendPost() { + val nip47Update = + if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + val pubkeyHex = + try { + decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } + + if (pubkeyHex != null && relayUrl != null) { + Nip47WalletConnect.Nip47URINorm( + pubkeyHex, + relayUrl, + privKeyHex, + ) + } else { + null + } + } else { + null + } + + accountViewModel.updateZapAmounts(amountSet, selectedZapType, nip47Update) + + nextAmount = TextFieldValue("") + } + + fun cancel() { + nextAmount = TextFieldValue("") + } + + fun hasChanged(): Boolean = + ( + selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || + amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || + walletConnectPubkey.text != ( + accountViewModel.account.settings.zapPaymentRequest.value + ?.pubKeyHex ?: "" + ) || + walletConnectRelay.text != ( + accountViewModel.account.settings.zapPaymentRequest.value + ?.relayUri ?: "" + ) || + walletConnectSecret.text != ( + accountViewModel.account.settings.zapPaymentRequest.value + ?.secret ?: "" + ) + ) + + fun updateNIP47(uri: String) { + val contact = Nip47WalletConnect.parse(uri) + walletConnectPubkey = TextFieldValue(contact.pubKeyHex) + walletConnectRelay = TextFieldValue(contact.relayUri.url) + walletConnectSecret = TextFieldValue(contact.secret ?: "") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 59282e5704..7e862d7af5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index ee92e08b65..3cfbaec4af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -42,8 +42,8 @@ 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 -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes @@ -373,7 +373,8 @@ fun WatchUserFollows( if (accountViewModel.isLoggedUser(userHex)) { onFollowChanges(true) } else { - val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val state by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() onFollowChanges(state.authors.contains(userHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 1e45e75392..e403f47965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -88,7 +88,7 @@ fun WatchAuthorWithBlank( inner(noteAuthor) } else { val authorState by observeNote(baseNote, accountViewModel) - CrossfadeIfEnabled(targetState = authorState?.note?.author, modifier = modifier, label = "WatchAuthorWithBlank", accountViewModel = accountViewModel) { newAuthor -> + CrossfadeIfEnabled(targetState = authorState.note.author, modifier = modifier, label = "WatchAuthorWithBlank", accountViewModel = accountViewModel) { newAuthor -> inner(newAuthor) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index b6459f1c3e..0ebb39184b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -84,7 +84,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException -class ZapOptionstViewModel : ViewModel() { +class ZapOptionViewModel : ViewModel() { private var account: Account? = null var customAmount by mutableStateOf(TextFieldValue("21")) @@ -111,7 +111,7 @@ fun ZapCustomDialog( baseNote: Note, ) { val context = LocalContext.current - val postViewModel: ZapOptionstViewModel = viewModel() + val postViewModel: ZapOptionViewModel = viewModel() LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } @@ -214,11 +214,7 @@ fun ZapCustomDialog( TextSpinner( label = stringRes(id = R.string.zap_type), - placeholder = - zapTypes - .filter { it.first == accountViewModel.defaultZapType() } - .first() - .second, + placeholder = zapTypes.first { it.first == accountViewModel.defaultZapType() }.second, options = zapOptions, onSelect = { selectedZapType = zapTypes[it].first }, modifier = Modifier.weight(1f).padding(end = 5.dp), @@ -232,15 +228,16 @@ fun ZapCustomDialog( OutlinedTextField( // stringRes(R.string.new_amount_in_sats label = { - if ( - selectedZapType == LnZapEvent.ZapType.PUBLIC || - selectedZapType == LnZapEvent.ZapType.ANONYMOUS - ) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) - } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) - } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + when (selectedZapType) { + LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) + } + LnZapEvent.ZapType.PRIVATE -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) + } + LnZapEvent.ZapType.NONZAP -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + } } }, value = postViewModel.customMessage, @@ -295,7 +292,7 @@ fun PayViaIntentDialog( if (payingInvoices.size == 1) { val payable = payingInvoices.first() payViaIntent(payable.invoice, context, onClose) { - onError(UserBasedErrorMessage(it, payable.user)) + onError(UserBasedErrorMessage(it, payable.info.user)) } } else { Dialog( @@ -326,8 +323,8 @@ fun PayViaIntentDialog( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp), ) { - if (payable.user != null) { - BaseUserPicture(payable.user, Size55dp, accountViewModel = accountViewModel) + if (payable.info.user != null) { + BaseUserPicture(payable.info.user, Size55dp, accountViewModel = accountViewModel) } else { DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel) } @@ -335,8 +332,8 @@ fun PayViaIntentDialog( Spacer(modifier = DoubleHorzSpacer) Column(modifier = Modifier.weight(1f)) { - if (payable.user != null) { - UsernameDisplay(payable.user, accountViewModel = accountViewModel) + if (payable.info.user != null) { + UsernameDisplay(payable.info.user, accountViewModel = accountViewModel) } else { Text( text = stringRes(id = R.string.wallet_number, index + 1), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 6044d99146..6dd97fd556 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -46,8 +46,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 1c75337450..044e66a493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -34,8 +34,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ZapUserSetCard import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt index 13080fc997..59baf599eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder @Composable fun InvoiceRequestCard( lud16: String, - toUserPubKeyHex: String, + user: User, accountViewModel: AccountViewModel, titleText: String? = null, buttonText: String? = null, @@ -81,7 +82,7 @@ fun InvoiceRequestCard( ) { InvoiceRequest( lud16, - toUserPubKeyHex, + user, accountViewModel, titleText, buttonText, @@ -94,11 +95,11 @@ fun InvoiceRequestCard( @Composable fun InvoiceRequest( lud16: String, - toUserPubKeyHex: String, + user: User, accountViewModel: AccountViewModel, titleText: String? = null, buttonText: String? = null, - onSuccess: (String) -> Unit, + onNewInvoice: (String) -> Unit, onError: (String, String) -> Unit, ) { val context = LocalContext.current @@ -176,11 +177,11 @@ fun InvoiceRequest( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { accountViewModel.sendSats( - lnaddress = lud16, + lnAddress = lud16, + user = user, milliSats = amount * 1000, message = message, - toUserPubKeyHex = toUserPubKeyHex, - onSuccess = onSuccess, + onNewInvoice = onNewInvoice, onError = onError, onProgress = {}, context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt index 5ee80d2f14..a2e04533e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt @@ -30,15 +30,21 @@ fun NewPostInvoiceRequest( onSuccess: (String) -> Unit, accountViewModel: AccountViewModel, ) { - accountViewModel.account.userProfile().info?.lnAddress()?.let { lud16 -> + val lnAddress = + accountViewModel.account + .userProfile() + .info + ?.lnAddress() + + if (lnAddress != null) { InvoiceRequest( - lud16, - accountViewModel.account.userProfile().pubkeyHex, - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = onSuccess, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + lud16 = lnAddress, + user = accountViewModel.account.userProfile(), + accountViewModel = accountViewModel, + titleText = stringRes(id = R.string.lightning_invoice), + buttonText = stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = onSuccess, + onError = accountViewModel.toastManager::toast, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt index b624e54594..87ddf7a021 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt @@ -28,7 +28,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index 286b57aebf..7be2f5204f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -50,10 +50,9 @@ import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlin.text.startsWith @Composable fun PreviewUrl( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 9d5e150b97..a119bc8a94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -98,7 +98,8 @@ fun ImageVideoDescription( ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList + .collectAsState() val fileServerOptions = remember(fileServers, includeNIP95) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 4fffbc7b57..373a6f1d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -46,7 +46,10 @@ import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.AnimateOnNewSearch import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ShowUserSuggestionList( @@ -62,8 +65,15 @@ fun ShowUserSuggestionList( AnimateOnNewSearch(userSuggestions, listState) LaunchedEffect(Unit) { - LocalCache.live.newEventBundles.collect { - userSuggestions.invalidateData() + launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { + userSuggestions.invalidateData() + } + } + launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { + userSuggestions.invalidateData() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 7bbf6fb9d6..453ab6a778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -42,7 +42,7 @@ class UserSuggestionState( ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") - val searchDataSourceState = SearchQueryState(MutableStateFlow("")) + val searchDataSourceState = SearchQueryState(MutableStateFlow(""), accountViewModel.account) @OptIn(FlowPreview::class) val searchTerm = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt index 9bc2c30c71..1928d52943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt @@ -65,7 +65,7 @@ fun PollField(postViewModel: ShortNotePostViewModel) { ), ) { Image( - painterRes(id = android.R.drawable.ic_input_add), + painter = painterRes(resourceId = android.R.drawable.ic_input_add, 1), contentDescription = "Add poll option button", modifier = Size18Modifier, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt index 1e7d8aca28..52c1b032e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt @@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index fb58d929fd..45684c691d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -39,8 +39,8 @@ 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.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -102,7 +102,7 @@ fun ObserveRelayListForDMs( inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit, ) { LoadAddressableNote( - ChatMessageRelayListEvent.createAddressTag(pubkey), + ChatMessageRelayListEvent.createAddress(pubkey), accountViewModel, ) { relayList -> if (relayList != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt index 61f5fd9bba..5d0c30fdc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -22,14 +22,12 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -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.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -40,6 +38,8 @@ import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SimpleHeaderImage +import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader @@ -47,10 +47,28 @@ import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader fun DefaultImageHeader( note: Note, accountViewModel: AccountViewModel, + modifier: Modifier = SimpleHeaderImage, ) { WatchAuthor(baseNote = note, accountViewModel) { Box { - BannerImage(it, Modifier.fillMaxWidth().heightIn(max = 200.dp), accountViewModel) + BannerImage(it, modifier, accountViewModel) + + Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) { + BaseUserPicture(it, Size55dp, accountViewModel, Modifier) + } + } + } +} + +@Composable +fun DefaultImageHeaderBackground( + note: Note, + accountViewModel: AccountViewModel, + modifier: Modifier = SimpleHeaderImage, +) { + WatchAuthor(baseNote = note, accountViewModel) { + Box { + BannerImage(it, modifier.blur(Size16dp), accountViewModel) Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) { BaseUserPicture(it, Size55dp, accountViewModel, Modifier) @@ -88,9 +106,17 @@ fun BannerImage( mainImageModifier = Modifier, loadedImageModifier = modifier, accountViewModel = accountViewModel, + onLoadingBackground = { + Image( + painter = painterRes(R.drawable.profile_banner, 4), + contentDescription = stringRes(R.string.profile_banner), + contentScale = ContentScale.Crop, + modifier = modifier, + ) + }, onError = { Image( - painter = painterRes(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 4), contentDescription = stringRes(R.string.profile_banner), contentScale = ContentScale.Crop, modifier = modifier, @@ -99,7 +125,7 @@ fun BannerImage( ) } else { Image( - painter = painterRes(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 5), contentDescription = stringRes(R.string.profile_banner), contentScale = ContentScale.Crop, modifier = modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 2b56d62505..877b417d41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -30,8 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.buildLinkString -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -59,7 +59,7 @@ private fun DisplayCommunity( val displayTag = remember(note) { - buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.toTag())) } + buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)) } } Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index 074168dc71..13a3f70c0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -37,8 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.buildLinkString -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -50,7 +50,8 @@ fun DisplayFollowingHashtagsInPost( accountViewModel: AccountViewModel, nav: INav, ) { - val userFollowState by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val userFollowState by accountViewModel.account.allFollows.flow + .collectAsStateWithLifecycle() var firstTag by remember(baseNote) { mutableStateOf(null) } LaunchedEffect(key1 = userFollowState) { @@ -70,7 +71,7 @@ fun DisplayFollowingHashtagsInPost( firstTag?.let { Column(verticalArrangement = Arrangement.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, nav) } + Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, accountViewModel, nav) } } } } @@ -78,6 +79,7 @@ fun DisplayFollowingHashtagsInPost( @Composable private fun DisplayTagList( firstTag: String, + accountViewModel: AccountViewModel, nav: INav, ) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index 962c771be3..33a443a547 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -28,14 +28,16 @@ import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Font14SP @Composable fun DisplayLocation( geohashStr: String, + accountViewModel: AccountViewModel, nav: INav, ) { LoadCityName(geohashStr) { cityName -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 6dd9e708d3..e1e0609409 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -59,8 +59,8 @@ 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 +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton @@ -70,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.math.BigDecimal @@ -181,7 +180,6 @@ class AddBountyAmountViewModel : ViewModel() { newValue, bountyInner, draftTag = null, - relayList = myAccount.activeWriteRelays().toImmutableList(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index 5d06653453..2ff50fbc24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -29,8 +29,9 @@ import androidx.compose.runtime.produceState import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.ui.components.ClickableTextColor -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Event @@ -43,9 +44,10 @@ import kotlinx.coroutines.launch fun DisplayUncitedHashtags( event: Event, callbackUri: String? = null, + accountViewModel: AccountViewModel, nav: INav, ) { - DisplayUncitedHashtags(event, event.content, callbackUri, nav) + DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) } @OptIn(ExperimentalLayoutApi::class) @@ -54,6 +56,7 @@ fun DisplayUncitedHashtags( event: Event, content: String, callbackUri: String? = null, + accountViewModel: AccountViewModel, nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 5969cec19d..ef1a115562 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -46,8 +46,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.note.types.EditState @@ -60,7 +60,6 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Composable fun MoreOptionsButton( @@ -187,7 +186,9 @@ fun NoteDropDownMenu( text = { Text(stringRes(R.string.copy_text)) }, onClick = { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } + accountViewModel.decrypt(lastNoteVersion) { + clipboardManager.setText(AnnotatedString(it)) + } onDismiss() }, ) @@ -272,7 +273,7 @@ fun NoteDropDownMenu( }, ) HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.hasPendingAttestations(note)) { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { DropdownMenuItem( text = { Text(stringRes(R.string.timestamp_pending)) }, onClick = { @@ -359,24 +360,20 @@ fun WatchBookmarksFollowsAndAccount( val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { - withContext(Dispatchers.IO) { - accountViewModel.isInPrivateBookmarks(note) { - val newState = - DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = it, - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitiveOrNSFW() ?: false, - showSensitiveContent = showSensitiveContent, - ) + val newState = + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note), + isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitiveOrNSFW() ?: false, + showSensitiveContent = showSensitiveContent, + ) - launch(Dispatchers.Main) { - onNew( - newState, - ) - } - } + launch(Dispatchers.Main) { + onNew( + newState, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index 04286f945f..b8fa57650c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -39,8 +39,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.appendLink -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -85,7 +85,7 @@ fun ForkInformationRowLightColor( val noteState by observeNote(originalVersion, accountViewModel) val note = noteState?.note ?: return val author = note.author ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { Row(modifier) { @@ -130,7 +130,7 @@ fun ForkInformationRow( ) { val noteState by observeNote(originalVersion, accountViewModel) val note = noteState?.note ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { Row(modifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index a4b257f7ca..1826aa018c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -67,10 +67,10 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.appendLink -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.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.CloseIcon import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog @@ -236,7 +236,7 @@ fun ZapTheDevsCard( if (noteEvent != null) { val route = remember(releaseNote) { - routeFor(releaseNote, accountViewModel.userProfile()) + routeFor(releaseNote, accountViewModel.account) } if (route != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index d9fb112547..61cf447429 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -99,12 +100,10 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlin.collections.plus @Stable open class CommentPostViewModel : @@ -130,7 +129,7 @@ open class CommentPostViewModel : var account: Account? = null var externalIdentity by mutableStateOf(null) - var replyingTo: Note? by mutableStateOf(null) + var replyingTo: Note? by mutableStateOf(null) val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>( @@ -176,9 +175,6 @@ open class CommentPostViewModel : var wantsZapraiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) - var showRelaysDialog by mutableStateOf(false) - var relayList by mutableStateOf?>(null) - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null @@ -210,14 +206,12 @@ open class CommentPostViewModel : if (noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } @@ -269,15 +263,15 @@ open class CommentPostViewModel : canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null multiOrchestrator = null - val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } forwardZapTo.value = SplitBuilder() - localfowardZapTo.forEach { + localForwardZapTo.forEach { val user = LocalCache.getOrCreateUser(it[1]) val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } forwardZapToEditting.value = TextFieldValue("") - wantsForwardZapTo = localfowardZapTo.isNotEmpty() + wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -310,21 +304,19 @@ open class CommentPostViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val relayList = relayList + val extraNotesToBroadcast = mutableListOf() - if (nip95attachments.isNotEmpty() && relayList != null) { + if (nip95attachments.isNotEmpty()) { val usedImages = template.tags.taggedQuoteIds().toSet() nip95attachments.forEach { - if (usedImages.contains(it.second.id) == true) { - account?.sendNip95Privately(it.first, it.second, relayList) + if (usedImages.contains(it.second.id)) { + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) } } } - accountViewModel?.account?.signAndSendPrivatelyOrBroadcast( - template, - relayList = { relayList }, - ) + accountViewModel?.account?.signAndComputeBroadcast(template, extraNotesToBroadcast) accountViewModel?.deleteDraft(draftTag.current) @@ -373,11 +365,11 @@ open class CommentPostViewModel : if (replyingTo != null) { val eventHint = replyingTo.toEventHint() ?: return null - CommentEvent.Companion.replyBuilder( + CommentEvent.replyBuilder( msg = tagger.message, replyingTo = eventHint, ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } + tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) } hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) @@ -393,11 +385,11 @@ open class CommentPostViewModel : } } else { val externalIdentity = externalIdentity ?: return null - CommentEvent.Companion.replyExternalIdentity( + CommentEvent.replyExternalIdentity( msg = tagger.message, extId = externalIdentity, ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } + tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) } hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) @@ -421,7 +413,7 @@ open class CommentPostViewModel : myEmojiSet: List?, ): List { if (myEmojiSet == null) return emptyList() - return CustomEmoji.Companion.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag( it.code, @@ -438,6 +430,22 @@ open class CommentPostViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, ) { viewModelScope.launch(Dispatchers.Default) { val myAccount = account ?: return@launch @@ -447,7 +455,6 @@ open class CommentPostViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, alt, contentWarningReason, MediaCompressor.Companion.intToCompressorQuality(mediaQuality), @@ -457,31 +464,30 @@ open class CommentPostViewModel : ) if (results.allGood) { - results.successful.forEach { - if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = myAccount.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) - } + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message) } - } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = - IMetaTagBuilder(it.result.url) + IMetaTagBuilder(state.result.url) .apply { - hash(it.result.fileHeader.hash) - size(it.result.fileHeader.size) - it.result.fileHeader.mimeType + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType ?.let { mimeType(it) } - it.result.fileHeader.dim + state.result.fileHeader.dim ?.let { dims(it) } - it.result.fileHeader.blurHash + state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } - it.result.magnet?.let { magnet(it) } - it.result.uploadedHash?.let { originalHash(it) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } contentWarningReason?.let { sensitiveContent(contentWarningReason) } @@ -489,7 +495,7 @@ open class CommentPostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(it.result.url) + message = message.insertUrlAtCursor(state.result.url) urlPreviews.update(message) } } @@ -497,13 +503,14 @@ open class CommentPostViewModel : multiOrchestrator = null } else { val errorMessages = - results.errors.map { - stringRes( - context, - it.errorResource, - *it.params, - ) - }.distinct() + results.errors + .map { + stringRes( + context, + it.errorResource, + *it.params, + ) + }.distinct() onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } @@ -544,29 +551,9 @@ open class CommentPostViewModel : emojiSuggestions?.reset() - showRelaysDialog = false - - reloadRelaySet() - draftTag.rotate() } - fun reloadRelaySet() { - val account = accountViewModel?.account ?: return - - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } - } - fun deleteMediaToUpload(selected: SelectedMediaProcessing) { this.multiOrchestrator?.remove(selected) } @@ -575,8 +562,8 @@ open class CommentPostViewModel : notifying = notifying?.filter { it != userToRemove } } - override fun updateMessage(it: TextFieldValue) { - message = it + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage urlPreviews.update(message) if (message.selection.collapsed) { @@ -633,10 +620,13 @@ open class CommentPostViewModel : val wordToInsert = item.link.url + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare( - item.link.url, - { Amethyst.Companion.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, - ) + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.Companion.instance.okHttpClients + .getHttpClient( + accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) + ?: false, + ) + } } message = message.replaceCurrentWord(wordToInsert) @@ -672,7 +662,7 @@ open class CommentPostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt index c285b862d0..da19175f11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -29,11 +30,12 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Composable fun DisplayExternalId( externalId: ExternalId, + accountViewModel: AccountViewModel, nav: INav, ) { when (externalId) { - is GeohashId -> DisplayGeohashExternalId(externalId, nav) - is HashtagId -> DisplayHashtagExternalId(externalId, nav) + is GeohashId -> DisplayGeohashExternalId(externalId, accountViewModel, nav) + is HashtagId -> DisplayHashtagExternalId(externalId, accountViewModel, nav) else -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt index 4f6a963830..c8afa029e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt @@ -34,14 +34,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -50,10 +52,21 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId @Composable fun DisplayGeohashExternalId( externalId: GeohashId, + accountViewModel: AccountViewModel, nav: INav, +) { + DisplayGeohashExternalId(externalId.geohash) { + nav.nav(Route.Geohash(externalId.geohash)) + } +} + +@Composable +fun DisplayGeohashExternalId( + geohash: String, + linkInteractionListener: LinkInteractionListener, ) { Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - LoadCityName(externalId.geohash) { cityName -> + LoadCityName(geohash) { cityName -> Icon( imageVector = Icons.Default.LocationOn, contentDescription = stringRes(id = R.string.geohash_exclusive), @@ -67,7 +80,7 @@ fun DisplayGeohashExternalId( text = buildAnnotatedString { withLink( - LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(externalId.geohash)) }, + LinkAnnotation.Clickable("cityname", null, linkInteractionListener), ) { append(cityName) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt index 8105c2b29b..824e380047 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt @@ -34,13 +34,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -49,7 +51,18 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Composable fun DisplayHashtagExternalId( externalId: HashtagId, + accountViewModel: AccountViewModel, nav: INav, +) { + DisplayHashtagExternalId(externalId.topic) { + nav.nav(Route.Hashtag(externalId.topic)) + } +} + +@Composable +fun DisplayHashtagExternalId( + topic: String, + linkInteractionListener: LinkInteractionListener, ) { Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Icon( @@ -65,9 +78,9 @@ fun DisplayHashtagExternalId( text = buildAnnotatedString { withLink( - LinkAnnotation.Clickable("hashtag") { nav.nav(Route.Hashtag(externalId.topic)) }, + LinkAnnotation.Clickable("hashtag", null, linkInteractionListener), ) { - append(externalId.topic) + append(topic) } }, style = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 0fa2e2d664..d05efddcea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.net.Uri import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -37,16 +35,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -56,16 +49,14 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.navigation.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -85,13 +76,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import kotlinx.collections.immutable.persistentListOf @@ -116,7 +105,6 @@ fun ReplyCommentPostScreen( val context = LocalContext.current LaunchedEffect(Unit) { - postViewModel.reloadRelaySet() reply?.let { postViewModel.reply(it) } @@ -151,73 +139,28 @@ fun GenericCommentPostScreen( Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { postViewModel.showRelaysDialog = true }, - ) { - Icon( - painter = painterRes(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - PostButton( - onPost = { - // uses the accountViewModel scope to avoid cancelling this - // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendPostSync() - nav.popBack() - } - }, - isActive = postViewModel.canPost(), - ) + PostingTopBar( + isActive = postViewModel::canPost, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - // uses the accountViewModel scope to avoid cancelling this - // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendDraftSync() - nav.popBack() - postViewModel.cancel() - } - }, - ) + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendPostSync() + nav.popBack() } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> - if (postViewModel.showRelaysDialog) { - RelaySelectionDialogEasy( - preSelectedList = postViewModel.relayList ?: persistentListOf(), - onClose = { postViewModel.showRelaysDialog = false }, - onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, - accountViewModel = accountViewModel, - nav = nav, - ) - } Surface( modifier = Modifier @@ -255,7 +198,7 @@ private fun GenericCommentPostBody( Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { postViewModel.externalIdentity?.let { Row { - DisplayExternalId(it, nav) + DisplayExternalId(it, accountViewModel, nav) Spacer(modifier = StdVertSpacer) } } @@ -351,11 +294,11 @@ private fun GenericCommentPostBody( postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( lud16, - accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account.userProfile(), accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.insertAtCursor(it) postViewModel.wantsInvoice = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index c09bdf48d0..f690866fdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LinkIcon import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -122,7 +122,7 @@ fun RenderAppDefinition( } } else { Image( - painter = painterRes(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 6), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index febeb1d0d8..718b6e378a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -45,9 +45,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -183,7 +184,7 @@ fun AudioHeader( nav: INav, ) { val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom() } + val waveform = remember { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } val content = remember { noteEvent.content.ifBlank { null } } val defaultBackground = MaterialTheme.colorScheme.background @@ -236,7 +237,7 @@ fun AudioHeader( if (noteEvent.hasHashtags()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - DisplayUncitedHashtags(noteEvent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 2d90c83404..7b770f447c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -47,7 +47,7 @@ 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.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index 75aa3569bb..9709ac8906 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -31,9 +31,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -60,9 +60,8 @@ fun RenderChannelMessage( } showChannelInfo?.let { - ChannelHeader( + PublicChatChannelHeader( channelHex = it, - showVideo = false, sendToChannel = true, modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt index 12b858d905..3c75950cd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -62,7 +62,7 @@ fun RenderChatMessage( userRoom?.let { if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { - routeFor(note, accountViewModel.userProfile())?.let { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt index 06a262ab95..0b20944483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt @@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader @@ -66,7 +66,7 @@ fun RenderChatMessageEncryptedFile( userRoom?.let { if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { - routeFor(note, accountViewModel.userProfile())?.let { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index 55720df0b4..b4e0ab5bf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -41,7 +42,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -99,7 +100,7 @@ fun RenderClassifieds( ) } } ?: run { - DefaultImageHeader(note, accountViewModel) + DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index c8b691ddb9..60346dd4a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -54,8 +54,9 @@ 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 -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture @@ -100,7 +101,7 @@ fun RenderCommunity( Row( MaterialTheme.colorScheme.innerPostModifier .clickable { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } + routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }.padding(Size10dp), ) { ShortCommunityHeader( @@ -170,6 +171,7 @@ fun LongCommunityHeader( DisplayUncitedHashtags( event = it, content = summary ?: "", + accountViewModel = accountViewModel, nav = nav, ) } @@ -360,9 +362,10 @@ fun WatchAddressableNoteFollows( accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit, ) { - val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val state by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() - onFollowChanges(state.addresses.contains(note.idHex)) + onFollowChanges(state.communities.contains(note.idHex)) } @Composable @@ -371,11 +374,13 @@ fun JoinCommunityButton( note: AddressableNote, nav: INav, ) { - val scope = rememberCoroutineScope() - Button( modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.follow(note) + } + }, shape = ButtonBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index e4c22a0674..844d9e47cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -185,9 +185,9 @@ private fun EmojiListOptions( CrossfadeIfEnabled(targetState = hasAddedThis, label = "EmojiListOptions", accountViewModel = accountViewModel) { if (it != true) { - AddButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + AddButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.addEmojiPack(emojiPackNote) } } else { - RemoveButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + RemoveButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.removeEmojiPack(emojiPackNote) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index e5938642cf..ec18a8c735 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text @@ -47,19 +46,22 @@ 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 coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -87,20 +89,26 @@ fun DisplayFollowList( val image = noteEvent.image() image?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth().padding(top = 5.dp).heightIn(max = 150.dp), + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = FollowSetImageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, ) + } ?: run { + DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) } Text( - text = noteEvent.nameOrTitle() ?: noteEvent.dTag(), + text = noteEvent.title() ?: noteEvent.dTag(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index bd509b98d8..bf5aad6cb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -46,7 +46,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -199,6 +199,7 @@ private fun RenderGitPatchEvent( event = noteEvent, content = eventContent, callbackUri = callbackUri, + accountViewModel = accountViewModel, nav = nav, ) } @@ -301,7 +302,7 @@ private fun RenderGitIssueEvent( } if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 828c6cbfe9..d25ae7476a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -49,8 +49,8 @@ import com.vitorpamplona.amethyst.ui.components.DisplayEvent import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -262,7 +262,7 @@ fun DisplayEntryForNote( RenderUserAsClickableText(author, null, accountViewModel, nav) } - val noteEvent = noteState?.note?.event as? BaseThreadedEvent ?: return + val noteEvent = noteState.note.event as? BaseThreadedEvent ?: return val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject", "alt") } @@ -271,10 +271,10 @@ fun DisplayEntryForNote( if (description != null) { ClickableTextPrimary( text = description, - onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav.nav(it) } }, + onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } }, ) } else { - DisplayEvent(noteEvent.id, noteEvent.kind, note.toNostrUri(), null, accountViewModel, nav) + DisplayEvent(noteEvent.id, note.toNostrUri(), null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt index 4da053d35f..cf124de8f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 853534e55a..90949d90cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -52,13 +52,14 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.MyAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag @@ -162,19 +163,22 @@ fun RenderLiveActivityEventInner( CrossfadeIfEnabled(targetState = status, label = "RenderLiveActivityEventInner", accountViewModel = accountViewModel) { when (it) { - StatusTag.STATUS.LIVE.code -> { + StatusTag.STATUS.LIVE -> { media?.let { CrossfadeCheckIfVideoIsOnline(it, accountViewModel) { LiveFlag() } } } - StatusTag.STATUS.PLANNED.code -> { + StatusTag.STATUS.PLANNED -> { ScheduledFlag(starts) } + + StatusTag.STATUS.ENDED -> {} + null -> {} } } } media?.let { media -> - if (status == StatusTag.STATUS.LIVE.code) { + if (status == StatusTag.STATUS.LIVE) { CheckIfVideoIsOnline(media, accountViewModel) { isOnline -> if (isOnline) { Row( @@ -209,7 +213,7 @@ fun RenderLiveActivityEventInner( } } } else { - if (status == StatusTag.STATUS.ENDED.code || (status == StatusTag.STATUS.PLANNED.code && (starts ?: 0) < TimeUtils.eightHoursAgo())) { + if (status == StatusTag.STATUS.ENDED || (status == StatusTag.STATUS.PLANNED && (starts ?: 0) < TimeUtils.eightHoursAgo())) { Box( contentAlignment = Alignment.Center, modifier = @@ -232,10 +236,11 @@ fun RenderLiveActivityEventInner( mainImageModifier = Modifier.fillMaxWidth(), loadedImageModifier = MaterialTheme.colorScheme.imageModifier, accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, onError = { DefaultImageHeader(baseNote, accountViewModel) }, ) } - } ?: run { DisplayAuthorBanner(baseNote, accountViewModel) } + } ?: run { DisplayAuthorBanner(baseNote, accountViewModel, MaterialTheme.colorScheme.imageModifier) } Text( text = stringRes(id = R.string.live_stream_has_ended), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index d66ac09bcc..6526e77f4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -31,9 +31,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -53,21 +54,24 @@ fun RenderLiveActivityChatMessage( val showChannelInfo = remember(noteEvent) { if (noteEvent is LiveActivitiesChatMessageEvent) { - noteEvent.activity()?.toTag() + noteEvent.activityAddress() } else { null } } showChannelInfo?.let { - ChannelHeader( - channelHex = it, - showVideo = false, - sendToChannel = true, - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), - accountViewModel = accountViewModel, - nav = nav, - ) + LoadLiveActivityChannel(it, accountViewModel) { + LiveActivitiesChannelHeader( + baseChannel = it, + showVideo = false, + showFlag = true, + sendToChannel = true, + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } Spacer(modifier = StdVertSpacer) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index a07bf57d23..235cdea0e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -30,7 +29,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow @@ -38,14 +36,13 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.MyAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @Composable @@ -72,33 +69,20 @@ fun LongFormHeader( noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } - Column( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { + Column(MaterialTheme.colorScheme.replyModifier) { image?.let { MyAsyncImage( imageUrl = it, - contentDescription = - stringRes( - R.string.preview_card_image_for, - it, - ), + contentDescription = stringRes(R.string.preview_card_image_for, it), contentScale = ContentScale.FillWidth, mainImageModifier = Modifier.fillMaxWidth(), loadedImageModifier = Modifier, accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { - DefaultImageHeader(note, accountViewModel) + DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth()) } title?.let { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt index 6cfb180a50..6ee786b6ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.model.Resource import com.vitorpamplona.amethyst.model.VisionPrescription import com.vitorpamplona.amethyst.model.findReferenceInDb import com.vitorpamplona.amethyst.model.parseResourceBundleOrNull -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index a0017ed533..66c9bb98ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags @@ -73,6 +73,6 @@ fun RenderNIP90ContentDiscoveryResponse( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt index 400a910d2d..f4ce7b4152 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index a5209e249b..45d867a2d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -49,13 +49,13 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 9f9eae7730..67ad354aa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -41,7 +41,7 @@ import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index e9e341d3f3..39eafb0c38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PinIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 444d76d1f6..089b67877b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -32,10 +32,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PollNote import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -76,7 +77,7 @@ fun RenderPoll( val replyingTo = noteEvent.replyingToAddressOrEvent() if (replyingTo != null) { val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && newNote.channelHex() == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { + if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { newNote } else { note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } @@ -129,7 +130,7 @@ fun RenderPoll( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index ea568c9634..a17db97998 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -38,8 +38,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -77,7 +77,7 @@ fun RenderPrivateMessage( userRoom?.let { if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { - routeFor(note, accountViewModel.userProfile())?.let { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } @@ -124,7 +124,7 @@ fun RenderPrivateMessage( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt new file mode 100644 index 0000000000..69dd2052cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists + +@Composable +fun RenderPublicMessage( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PublicMessageEvent ?: return + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = noteEvent.content, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = noteEvent.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index 813decab40..eb2a23085a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index bdb99d55b9..f38e18a308 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -37,29 +37,31 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString 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.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.RelayListCard import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelayIntoList import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.AddRelayButton import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -74,7 +76,9 @@ fun DisplayRelaySet( val relays by remember(noteEvent) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + RelayListCard( + noteEvent.relays().toImmutableList(), + ), ) } @@ -105,14 +109,18 @@ fun DisplayNIP65RelayList( val writeRelays by remember(baseNote) { mutableStateOf( - noteEvent.writeRelays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + RelayListCard( + noteEvent.writeRelaysNorm() ?: emptyList(), + ), ) } val readRelays by remember(baseNote) { mutableStateOf( - noteEvent.readRelays()?.map { RelayBriefInfoCache.RelayBriefInfo(it) }?.toImmutableList() ?: persistentListOf(), + RelayListCard( + noteEvent.readRelaysNorm() ?: emptyList(), + ), ) } @@ -147,7 +155,9 @@ fun DisplayDMRelayList( val relays by remember(baseNote) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + RelayListCard( + noteEvent.relays(), + ), ) } @@ -168,14 +178,9 @@ fun DisplaySearchRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? SearchRelayListEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), - ) - } + val relays by accountViewModel.account.searchRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.searchRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) DisplayRelaySet( relays, @@ -187,9 +192,114 @@ fun DisplaySearchRelayList( ) } +@Composable +fun DisplayBlockedRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.blockedRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.blockedRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.blocked_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayTrustedRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.trustedRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.trustedRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.trusted_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayProxyRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.proxyRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.proxyRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.proxy_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayIndexerRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.indexerRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.indexerRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.indexer_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayBroadcastRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.broadcastRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.broadcastRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.broadcast_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + @Composable fun DisplayRelaySet( - relays: ImmutableList, + relay: RelayListCard, relayListName: String, relayDescription: String?, backgroundColor: MutableState, @@ -200,9 +310,9 @@ fun DisplayRelaySet( val toMembersShow = if (expanded) { - relays + relay.relays } else { - relays.take(3) + relay.relays.take(3) } Text( @@ -239,7 +349,7 @@ fun DisplayRelaySet( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = relay.displayUrl, + text = relay.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -250,13 +360,13 @@ fun DisplayRelaySet( ) Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptionsAction(relay.url, accountViewModel, nav) + RelayOptionsAction(relay, accountViewModel, nav) } } } } - if (relays.size > 3 && !expanded) { + if (relay.relays.size > 3 && !expanded) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -274,19 +384,21 @@ fun DisplayRelaySet( @Composable private fun RelayOptionsAction( - relay: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { val isCurrentlyOnTheUsersList by observeUserRelayIntoList(accountViewModel.userProfile(), relay, accountViewModel) + val clipboardManager = LocalClipboardManager.current if (isCurrentlyOnTheUsersList) { AddRelayButton { - nav.nav(Route.EditRelays(relay)) + clipboardManager.setText(AnnotatedString(relay.url)) + nav.nav(Route.EditRelays) } } else { RemoveRelayButton { - nav.nav(Route.EditRelays(relay)) + nav.nav(Route.EditRelays) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt index bf2e6e410f..3a3f336156 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt index ecb3011f5b..bbc44aab40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index c9da38a2ed..49867e15e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -33,18 +33,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @@ -66,7 +66,7 @@ fun RenderTextEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? Event ?: return + val noteEvent = note.event ?: return val showReply by remember(note) { @@ -82,7 +82,7 @@ fun RenderTextEvent( val replyingTo = noteEvent.replyingToAddressOrEvent() if (replyingTo != null) { val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && newNote.channelHex() == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { + if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { newNote } else { note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } @@ -163,7 +163,7 @@ fun RenderTextEvent( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index 8724e3e3b0..fa80c2a343 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -50,8 +50,8 @@ import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.NoteBody import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -177,7 +177,7 @@ fun RenderTextModificationEvent( .clickable { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index d8ebca3755..81a196ec63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -53,8 +53,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.countToHumanReadableBytes import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 80332b0ced..d9d91d3fb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -50,9 +50,9 @@ import com.vitorpamplona.amethyst.service.countToHumanReadableBytes import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -228,7 +228,7 @@ fun ShortTorrentHeader( ShortTorrentHeader( title = noteEvent?.title() ?: TorrentEvent.ALT_DESCRIPTION, size = noteEvent?.totalSizeBytes()?.let { countToHumanReadableBytes(it) } ?: "--", - modifier.clickable { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, + modifier.clickable { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 5c008c8cbc..0ae502b832 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -51,8 +51,9 @@ import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -142,6 +143,7 @@ fun VideoDisplay( mainImageModifier = Modifier, loadedImageModifier = MaterialTheme.colorScheme.imageModifier, accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { @@ -189,7 +191,7 @@ fun VideoDisplay( Row( Modifier.fillMaxWidth(), ) { - DisplayUncitedHashtags(event, summary, callbackUri, nav) + DisplayUncitedHashtags(event, summary, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt new file mode 100644 index 0000000000..a0ace2304a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent + +@Composable +fun RenderVoiceTrack( + note: Note, + contentScale: ContentScale, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? BaseVoiceEvent ?: return + + VoiceHeader(noteEvent, note, contentScale, accountViewModel, nav) +} + +@Composable +fun VoiceHeader( + noteEvent: BaseVoiceEvent, + note: Note, + contentScale: ContentScale, + accountViewModel: AccountViewModel, + nav: INav, +) { + val waveform = + remember(noteEvent) { + noteEvent + .iMetaTags() + .firstOrNull() + ?.waveform + ?.let { WaveformData(it) } + } + val media = + remember(noteEvent) { + noteEvent.content.ifBlank { null } ?: noteEvent.iMetaTags().firstOrNull()?.url + } + + if (media == null) return + + Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + VideoView( + videoUri = media, + mimeType = null, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = contentScale, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri(), + ) + } + + val callbackUri = remember(note) { note.toNostrUri() } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt index e4cb083458..2a12a87985 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt @@ -39,8 +39,9 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.MyAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder @@ -103,6 +104,7 @@ private fun WikiNoteHeader( mainImageModifier = Modifier, loadedImageModifier = MaterialTheme.colorScheme.imageModifier, accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 186cb844f5..0261ebd111 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -46,7 +46,7 @@ fun AccountScreen( ) { val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() - Log.d("ManageRelayServices", "AccountScreen $accountState $accountStateViewModel") + Log.d("ActivityLifecycle", "AccountScreen $accountState $accountStateViewModel") Crossfade( targetState = accountState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 2d07e3c0a9..7976be8ef3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -22,13 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route sealed class AccountState { object Loading : AccountState() @@ -55,11 +54,13 @@ fun SetAccountCentricViewModelStore( content() } - DisposableEffect(key1 = state) { - onDispose { - state.currentViewModelStore.viewModelStore.clear() - } - } + // moved this clearing activity to the viewmodel account + // because the new composable might run before the onDispose. + // DisposableEffect(key1 = state) { + // onDispose { + // state.currentViewModelStore.viewModelStore.clear() + // } + // } } class AccountCentricViewModelStore : ViewModelStoreOwner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 98f285f1ee..cbaecf3f0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -31,19 +31,19 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultNIP65List +import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.torSettings import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes 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.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay @@ -86,26 +87,30 @@ class AccountStateViewModel : ViewModel() { private var collectorJob: Job? = null - fun tryLoginExistingAccountAsync() { + fun loginWithDefaultAccountIfLoggedOff() { // pulls account from storage. if (_accountContent.value !is AccountState.LoggedIn) { viewModelScope.launch { - tryLoginExistingAccount() + loginWithDefaultAccount() } } } - private suspend fun tryLoginExistingAccount(route: Route? = null) = - withContext(Dispatchers.IO) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage() - }?.let { startUI(it, route) } ?: run { requestLoginUI() } + private suspend fun loginWithDefaultAccount(route: Route? = null) { + val accountSettings = + withContext(Dispatchers.IO) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage() + } - private suspend fun requestLoginUI() { - _accountContent.update { AccountState.LoggedOff } - - viewModelScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseAndLogOff() } + if (accountSettings != null) { + startUI(accountSettings, route) + } else { + requestLoginUI() + } } + private suspend fun requestLoginUI() = _accountContent.update { AccountState.LoggedOff } + suspend fun loginAndStartUI( key: String, torSettings: TorSettings, @@ -119,7 +124,7 @@ class AccountStateViewModel : ViewModel() { is NSec -> null is NPub -> parsed.hex.hexToByteArray() is NProfile -> parsed.hex.hexToByteArray() - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> null + is NNote -> null is NEvent -> null is NEmbed -> null is NRelay -> null @@ -170,7 +175,7 @@ class AccountStateViewModel : ViewModel() { ) } - LocalPreferences.updatePrefsForLogin(account) + LocalPreferences.setDefaultAccount(account) startUI(account) } @@ -236,7 +241,7 @@ class AccountStateViewModel : ViewModel() { } else if (EMAIL_PATTERN.matcher(key).matches()) { Nip05NostrAddressVerifier().verifyNip05( key, - okttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, onSuccess = { publicKey -> loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError) }, @@ -272,6 +277,9 @@ class AccountStateViewModel : ViewModel() { onError: (String) -> Unit, ) { try { + if (_accountContent.value is AccountState.LoggedIn) { + prepareLogoutOrSwitch() + } loginAndStartUI(key, torSettings, transientAccount, loginWithExternalSigner, packageName) } catch (e: Exception) { if (e is CancellationException) throw e @@ -285,56 +293,65 @@ class AccountStateViewModel : ViewModel() { name: String? = null, ) { viewModelScope.launch(Dispatchers.IO) { + if (_accountContent.value is AccountState.LoggedIn) { + prepareLogoutOrSwitch() + } + _accountContent.update { AccountState.Loading } - val keyPair = KeyPair() - val tempSigner = NostrSignerSync(keyPair) + val accountSettings = createNewAccount(torSettings, name) - val accountSettings = - AccountSettings( - keyPair = keyPair, - transientAccount = false, - backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), - backupContactList = - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = tempSigner, - ), - backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), - backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), - backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList, tempSigner), - backupChannelList = ChannelListEvent.create(DefaultChannels, tempSigner), - torSettings = TorSettingsFlow.build(torSettings), - ) - - // saves to local preferences - LocalPreferences.updatePrefsForLogin(accountSettings) + LocalPreferences.setDefaultAccount(accountSettings) startUI(accountSettings) @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. - accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it) } - accountSettings.backupContactList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it) } + + val toPost = accountSettings.backupNIP65RelayList?.writeRelaysNorm()?.toSet() ?: DefaultNIP65RelaySet + + accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupContactList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it, toPost) } } } } + fun createNewAccount( + torSettings: TorSettings, + name: String? = null, + ): AccountSettings { + val keyPair = KeyPair() + val tempSigner = NostrSignerSync(keyPair) + + return AccountSettings( + keyPair = keyPair, + transientAccount = false, + backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), + backupContactList = + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), + relayUse = emptyMap(), + signer = tempSigner, + ), + backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), + backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), + backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), + backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), + torSettings = TorSettingsFlow.build(torSettings), + ) + } + fun switchUser(accountInfo: AccountInfo) { viewModelScope.launch(Dispatchers.IO) { switchUserSync(accountInfo) } } - suspend fun switchUserSync( + suspend fun checkAndSwitchUserSync( npub: String, route: Route, ): Boolean { @@ -348,34 +365,33 @@ class AccountStateViewModel : ViewModel() { return false } - suspend fun switchUserSync( + private suspend fun switchUserSync( accountInfo: AccountInfo, route: Route? = null, ) { prepareLogoutOrSwitch() LocalPreferences.switchToAccount(accountInfo) - tryLoginExistingAccount(route) + loginWithDefaultAccount(route) } - fun currentAccount() = + fun currentAccountNPub() = when (val state = _accountContent.value) { is AccountState.LoggedIn -> state.accountSettings.keyPair.pubKey .toNpub() - else -> null } fun logOff(accountInfo: AccountInfo) { viewModelScope.launch(Dispatchers.IO) { - if (accountInfo.npub == currentAccount()) { + if (accountInfo.npub == currentAccountNPub()) { // log off and relogin with the 0 account prepareLogoutOrSwitch() - LocalPreferences.updatePrefsForLogout(accountInfo) - tryLoginExistingAccount() + LocalPreferences.deleteAccount(accountInfo) + loginWithDefaultAccount() } else { - // delete without login off - LocalPreferences.updatePrefsForLogout(accountInfo) + // delete without switching logins + LocalPreferences.deleteAccount(accountInfo) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 75e104d750..beb4807fdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 8df48eb4f8..62ad0b4d3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch @Stable @@ -48,21 +47,25 @@ abstract class FeedViewModel( override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) - private var collectorJob: Job? = null - init { Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - feedState.updateFeedWith(newNotes) - } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@FeedViewModel.javaClass.simpleName} with ${newNotes.size}") + feedState.updateFeedWith(newNotes) } + } + + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Delete from feeds: ${this@FeedViewModel.javaClass.simpleName} with ${newNotes.size}") + feedState.deleteFromFeed(newNotes) + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index 28f5749e25..a563316d3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -25,15 +25,15 @@ import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent @@ -45,10 +45,10 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -75,7 +75,7 @@ class FollowListState( ) { val kind3Follow = PeopleListOutBoxFeedDefinition( - code = KIND3_FOLLOWS, + code = ALL_FOLLOWS, name = ResourceName(R.string.follow_list_kind3follows), type = CodeNameType.HARDCODED, kinds = DEFAULT_FEED_KINDS, @@ -88,7 +88,6 @@ class FollowListState( name = ResourceName(R.string.follow_list_global), type = CodeNameType.HARDCODED, kinds = DEFAULT_FEED_KINDS, - relays = account.activeGlobalRelays().toList(), ) val aroundMe = @@ -161,20 +160,19 @@ class FollowListState( @OptIn(ExperimentalCoroutinesApi::class) val liveKind3FollowsFlow: Flow> = - account.liveKind3Follows.transformLatest { + account.kind3FollowList.flow.transformLatest { checkNotInMainThread() val communities = - it.addresses.mapNotNull { + it.communities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> TagFeedDefinition( "Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE, - route = Route.Community(communityNote.idHex), + route = Route.Community(communityNote.address.kind, communityNote.address.pubKeyHex, communityNote.address.dTag), kinds = DEFAULT_COMMUNITY_FEEDS, aTags = listOf(communityNote.idHex), - relays = account.activeGlobalRelays().toList(), ) } } @@ -188,7 +186,6 @@ class FollowListState( route = Route.Hashtag(it), kinds = DEFAULT_FEED_KINDS, tTags = listOf(it), - relays = account.activeGlobalRelays().toList(), ) } @@ -201,7 +198,6 @@ class FollowListState( route = Route.Geohash(it), kinds = DEFAULT_FEED_KINDS, gTags = listOf(it), - relays = account.activeGlobalRelays().toList(), ) } @@ -241,8 +237,14 @@ class FollowListState( ) } - val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) - val kind3GlobalPeople = _kind3GlobalPeople.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) + val kind3GlobalPeopleRoutes = + _kind3GlobalPeopleRoutes + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) + val kind3GlobalPeople = + _kind3GlobalPeople + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) suspend fun initializeSuspend() { checkNotInMainThread() @@ -295,7 +297,7 @@ class PeopleListName( return if (noteEvent is PeopleListEvent) { noteEvent.nameOrTitle() ?: note.dTag() } else if (noteEvent is FollowListEvent) { - noteEvent.nameOrTitle() ?: note.dTag() + noteEvent.title() ?: note.dTag() } else { note.dTag() } @@ -322,7 +324,6 @@ class GlobalFeedDefinition( name: Name, type: CodeNameType, val kinds: List, - val relays: List, ) : FeedDefinition(code, name, type, null) @Immutable @@ -332,7 +333,6 @@ class TagFeedDefinition( type: CodeNameType, route: Route?, val kinds: List, - val relays: List, val pTags: List? = null, val eTags: List? = null, val aTags: List? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 01ebfee6b2..a89651c332 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 8ee6a446c1..64d0813cba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -99,26 +98,26 @@ open class UserFeedViewModel( } } - var collectorJob: Job? = null - init { Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() } + } + + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Delete from feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 6ab8b01b5f..e3e361ebf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -37,9 +37,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalConfiguration -import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet -import com.vitorpamplona.amethyst.ui.navigation.DrawerContent -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f4d592aac7..3f5079dfc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -28,6 +28,7 @@ import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -35,10 +36,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil3.asDrawable import coil3.imageLoader import coil3.request.ImageRequest +import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.collectSuccessfulOperations import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.isDebug @@ -47,29 +48,34 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.WarningType +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator -import com.vitorpamplona.amethyst.service.CashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.Dao +import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.showAmount @@ -80,7 +86,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -90,15 +95,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -106,14 +109,17 @@ import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes 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.NNote 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.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -121,11 +127,12 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -134,7 +141,6 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -143,11 +149,12 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.util.Locale @Stable class AccountViewModel( @@ -156,35 +163,20 @@ class AccountViewModel( val app: Amethyst, ) : ViewModel(), Dao { - val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) + val account = + Account( + settings = accountSettings, + signer = accountSettings.createSigner(app.contentResolver), + geolocationFlow = app.locationManager.geohashStateFlow, + cache = LocalCache, + client = app.client, + scope = viewModelScope, + ) - val newNotesPreProcessor = PrecacheNewNotesProcessor(account, LocalCache) + val newNotesPreProcessor = DecryptAndIndexProcessor(account, LocalCache) var firstRoute: Route? = null - // TODO: contact lists are not notes yet - // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) - - val normalizedKind3RelaySetFlow = - account - .userProfile() - .flow() - .relays.stateFlow - .map { contactListState -> - checkNotInMainThread() - contactListState.user.latestContactList?.relays()?.map { - RelayUrlFormatter.normalize(it.key) - } ?: emptySet() - }.flowOn(Dispatchers.Default) - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(10000, 10000), - emptySet(), - ) - - val dmRelays: StateFlow = observeByAuthor(ChatMessageRelayListEvent.KIND, account.signer.pubKey) - val searchRelays: StateFlow = observeByAuthor(SearchRelayListEvent.KIND, account.signer.pubKey) - val toastManager = ToastManager() val feedStates = AccountFeedContentStates(this) @@ -205,7 +197,10 @@ class AccountViewModel( emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) } - val notificationHasNewItemsFlow = notificationHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + val notificationHasNewItemsFlow = + notificationHasNewItems + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) @OptIn(ExperimentalCoroutinesApi::class) val messagesHasNewItems = @@ -236,7 +231,10 @@ class AccountViewModel( } } - val messagesHasNewItemsFlow = messagesHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + val messagesHasNewItemsFlow = + messagesHasNewItems + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) @OptIn(ExperimentalCoroutinesApi::class) val homeHasNewItems = @@ -256,7 +254,10 @@ class AccountViewModel( emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) } - val homeHasNewItemsFlow = homeHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + val homeHasNewItemsFlow = + homeHasNewItems + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val hasNewItems = mapOf( @@ -269,13 +270,6 @@ class AccountViewModel( fun userProfile(): User = account.userProfile() - suspend fun reactTo( - note: Note, - reaction: String, - ) { - account.reactTo(note, reaction) - } - fun observeByETag( kind: Int, eTag: HexKey, @@ -290,8 +284,8 @@ class AccountViewModel( note: Note, reaction: String, ) { - viewModelScope.launch(Dispatchers.IO) { - val currentReactions = account.reactionTo(note, reaction) + runIOCatching { + val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { account.delete(currentReactions) } else { @@ -301,14 +295,8 @@ class AccountViewModel( } fun reactToOrDelete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - val reaction = reactionChoices().first() - if (hasReactedTo(note, reaction)) { - deleteReactionTo(note, reaction) - } else { - reactTo(note, reaction) - } - } + val reaction = reactionChoices().first() + reactToOrDelete(note, reaction) } @Immutable @@ -322,7 +310,7 @@ class AccountViewModel( fun isNoteAcceptable( note: Note, - accountChoices: Account.LiveHiddenUsers, + accountChoices: HiddenUsersState.LiveHiddenUsers, followUsers: Set, ): NoteComposeReportState { checkNotInMainThread() @@ -363,13 +351,21 @@ class AccountViewModel( fun createIsHiddenFlow(note: Note): StateFlow = noteIsHiddenFlows.get(note) ?: combineTransform( - account.flowHiddenUsers, - account.liveKind3Follows, + account.hiddenUsers.flow, + account.kind3FollowList.flow, note.flow().author(), note.flow().metadata.stateFlow, note.flow().reports.stateFlow, ) { hiddenUsers, followingUsers, autor, metadata, reports -> emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors)) + }.onStart { + emit( + isNoteAcceptable( + note, + account.hiddenUsers.flow.value, + account.kind3FollowList.flow.value.authors, + ), + ) }.flowOn(Dispatchers.Default) .stateIn( viewModelScope, @@ -397,24 +393,6 @@ class AccountViewModel( noteMustShowExpandButtonFlows.put(note, it) } - fun hasReactedTo( - baseNote: Note, - reaction: String, - ): Boolean = account.hasReacted(baseNote, reaction) - - suspend fun deleteReactionTo( - note: Note, - reaction: String, - ) { - account.delete(account.reactionTo(note, reaction)) - } - - fun hasBoosted(baseNote: Note): Boolean = account.hasBoosted(baseNote) - - fun deleteBoostsTo(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(account.boostsTo(note)) } - } - suspend fun calculateIfNoteWasZappedByAccount( zappedNote: Note, onWasZapped: (Boolean) -> Unit, @@ -424,42 +402,36 @@ class AccountViewModel( } } - suspend fun calculateZapAmount( - zappedNote: Note, - onZapAmount: (String) -> Unit, - ) { + suspend fun calculateZapAmount(zappedNote: Note): String = if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.Default) { - account.calculateZappedAmount(zappedNote) { onZapAmount(showAmount(it)) } + val it = account.calculateZappedAmount(zappedNote) + showAmount(it) } } else { - onZapAmount(showAmount(zappedNote.zapsAmount)) + showAmount(zappedNote.zapsAmount) } - } - suspend fun calculateZapraiser( - zappedNote: Note, - onZapraiserStatus: (ZapraiserStatus) -> Unit, - ) { + suspend fun calculateZapraiser(zappedNote: Note): ZapraiserStatus { val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 - if (zappedNote.zapPayments.isNotEmpty()) { + return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.Default) { - account.calculateZappedAmount(zappedNote) { newZapAmount -> - var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() + val newZapAmount = account.calculateZappedAmount(zappedNote) + var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() - if (percentage > 1) { - percentage = 1f + if (percentage > 1) { + percentage = 1f + } + + val newZapraiserProgress = percentage + val newZapraiserLeft = + if (percentage > 0.99) { + "0" + } else { + showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) } - val newZapraiserProgress = percentage - val newZapraiserLeft = - if (percentage > 0.99) { - "0" - } else { - showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) - } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) - } + ZapraiserStatus(newZapraiserProgress, newZapraiserLeft) } } else { var percentage = zappedNote.zapsAmount.div(zapraiserAmount.toBigDecimal()).toFloat() @@ -475,7 +447,8 @@ class AccountViewModel( } else { showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) + + ZapraiserStatus(newZapraiserProgress, newZapraiserLeft) } } @@ -503,22 +476,21 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulOperations( - items = zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, - runRequestFor = { next, onReady -> - checkNotInMainThread() - - innerDecryptAmountMessage(next.request, next.response) { - onReady(DecryptedInfo(next.request, next.response, it)) + val results = + mapNotNullAsync( + zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, + ) { next -> + val info = innerDecryptAmountMessage(next.request, next.response) + if (info != null) { + DecryptedInfo(next.request, next.response, info) + } else { + null } - }, - ) { - checkNotInMainThread() + } - it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } - onNewState(initialResults.values.toImmutableList()) - } + onNewState(initialResults.values.toImmutableList()) } } @@ -527,7 +499,7 @@ class AccountViewModel( .map { val request = it.request.event as? LnZapRequestEvent if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() + val cachedPrivateRequest = account.privateZapsDecryptionCache.cachedPrivateZap(request) if (cachedPrivateRequest != null) { ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, @@ -561,7 +533,7 @@ class AccountViewModel( .map { val request = it.first.event as? LnZapRequestEvent if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() + val cachedPrivateRequest = account.privateZapsDecryptionCache.cachedPrivateZap(request) if (cachedPrivateRequest != null) { ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, @@ -609,18 +581,19 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulOperations, DecryptedInfo>( - items = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.first, next.second) { - onReady(DecryptedInfo(next.first, next.second, it)) + val decryptedInfo = + mapNotNullAsync(myList) { next -> + val info = innerDecryptAmountMessage(next.first, next.second) + if (info != null) { + DecryptedInfo(next.first, next.second, info) + } else { + null } - }, - ) { - it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + } - onNewState(initialResults.values.toImmutableList()) - } + decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + + onNewState(initialResults.values.toImmutableList()) } } @@ -630,44 +603,39 @@ class AccountViewModel( onNewState: (ZapAmountCommentNotification?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) + onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)) } } - private fun innerDecryptAmountMessage( + private suspend fun innerDecryptAmountMessage( zapRequest: Note, zapEvent: Note?, - onReady: (ZapAmountCommentNotification) -> Unit, - ) { - checkNotInMainThread() - + ): ZapAmountCommentNotification? = (zapRequest.event as? LnZapRequestEvent)?.let { + val amount = showAmountInteger((zapEvent?.event as? LnZapEvent)?.amount) if (it.isPrivateZap()) { - decryptZap(zapRequest) { decryptedContent -> - val amount = (zapEvent?.event as? LnZapEvent)?.amount - val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - onReady( - ZapAmountCommentNotification( - newAuthor, - decryptedContent.content.ifBlank { null }, - showAmountInteger(amount), - ), + val decryptedContent = account.decryptZapOrNull(it) + if (decryptedContent != null) { + ZapAmountCommentNotification( + LocalCache.checkGetOrCreateUser(decryptedContent.pubKey), + decryptedContent.content.ifBlank { null }, + amount, + ) + } else { + ZapAmountCommentNotification( + zapRequest.author, + null, + amount, ) } } else { - val amount = (zapEvent?.event as? LnZapEvent)?.amount - if (!zapRequest.event?.content.isNullOrBlank() || amount != null) { - onReady( - ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content?.ifBlank { null }, - showAmountInteger(amount), - ), - ) - } + ZapAmountCommentNotification( + zapRequest.author, + zapRequest.event?.content?.ifBlank { null }, + amount, + ) } } - } fun zap( note: Note, @@ -680,25 +648,20 @@ class AccountViewModel( onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, - ) { - viewModelScope.launch(Dispatchers.IO) { - ZapPaymentHandler(account) - .zap( - note = note, - amountMilliSats = amountInMillisats, - pollOption = pollOption, - message = message, - context = context, - showErrorIfNoLnAddress = showErrorIfNoLnAddress, - okHttpClient = ::okHttpClientForMoney, - onError = onError, - onProgress = { - onProgress(it) - }, - onPayViaIntent = onPayViaIntent, - zapType = zapType ?: defaultZapType(), - ) - } + ) = runIOCatching { + ZapPaymentHandler(account).zap( + note = note, + amountMilliSats = amountInMillisats, + pollOption = pollOption, + message = message, + context = context, + showErrorIfNoLnAddress = showErrorIfNoLnAddress, + okHttpClient = ::okHttpClientForMoney, + onError = onError, + onProgress = { onProgress(it) }, + onPayViaIntent = onPayViaIntent, + zapType = zapType ?: defaultZapType(), + ) } fun report( @@ -706,85 +669,62 @@ class AccountViewModel( type: ReportType, content: String = "", ) { - viewModelScope.launch(Dispatchers.IO) { account.report(note, type, content) } + runIOCatching { account.report(note, type, content) } } fun report( user: User, type: ReportType, ) { - viewModelScope.launch(Dispatchers.IO) { + runIOCatching { account.report(user, type) account.hideUser(user.pubkeyHex) } } fun boost(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.boost(note) } + runIOCatching { account.boost(note) } } - fun removeEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(usersEmojiList, emojiList) } + fun removeEmojiPack(emojiPack: Note) { + runIOCatching { account.removeEmojiPack(emojiPack) } } - fun addEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiPack) } + fun addEmojiPack(emojiPack: Note) { + runIOCatching { account.addEmojiPack(emojiPack) } } fun addMediaToGallery( hex: String, url: String, - relay: String?, + relay: NormalizedRelayUrl?, blurhash: String?, dim: DimensionTag?, hash: String?, mimeType: String?, ) { - viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } + runIOCatching { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } } - fun removefromMediaGallery(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note) } + fun removeFromMediaGallery(note: Note) { + runIOCatching { account.removeFromGallery(note) } } - fun addPrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } - } + fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex)) - fun addPublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, false) } - } + fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) - fun removePrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, true) } - } + fun addPrivateBookmark(note: Note) = runIOCatching { account.addBookmark(note, true) } - fun removePublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, false) } - } + fun addPublicBookmark(note: Note) = runIOCatching { account.addBookmark(note, false) } - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - account.isInPrivateBookmarks(note, onReady) - } + fun removePrivateBookmark(note: Note) = runIOCatching { account.removeBookmark(note, true) } - fun isInPublicBookmarks(note: Note): Boolean = account.isInPublicBookmarks(note) + fun removePublicBookmark(note: Note) = runIOCatching { account.removeBookmark(note, false) } - fun broadcast(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) } - } + fun broadcast(note: Note) = runIOCatching { account.broadcast(note) } - fun timestamp(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) } - } + fun timestamp(note: Note) = runIOCatching { account.otsState.timestamp(note) } var lastTimeItTriedToUpdateAttestations: Long = 0 @@ -793,22 +733,16 @@ class AccountViewModel( val now = TimeUtils.now() if (now - lastTimeItTriedToUpdateAttestations > TimeUtils.ONE_HOUR) { lastTimeItTriedToUpdateAttestations = now - viewModelScope.launch(Dispatchers.IO) { account.updateAttestations() } - } - } - - fun precomputeNewEvents(notes: Set) { - viewModelScope.launch(Dispatchers.Default) { - newNotesPreProcessor.run(notes) + runIOCatching { account.updateAttestations() } } } fun delete(notes: List) { - viewModelScope.launch(Dispatchers.IO) { account.delete(notes) } + runIOCatching { account.delete(notes) } } fun delete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(note) } + runIOCatching { account.delete(note) } } fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note) @@ -818,63 +752,99 @@ class AccountViewModel( fun decrypt( note: Note, onReady: (String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } + ) = runIOCatching { + account.decryptContent(note)?.let { onReady(it) } } - fun decryptZap( - note: Note, - onReady: (Event) -> Unit, - ) { - account.decryptZapContentAuthor(note, onReady) + inline fun runIOCatching(crossinline action: suspend () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + action() + } catch (e: SignerExceptions.ReadOnlyException) { + toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } catch (e: SignerExceptions.UnauthorizedDecryptionException) { + toastManager.toast( + R.string.unauthorized_exception, + R.string.unauthorized_exception_description, + ) + } catch (e: SignerExceptions.SignerNotFoundException) { + toastManager.toast( + R.string.signer_not_found_exception, + R.string.signer_not_found_exception_description, + ) + } catch (e: SignerExceptions.TimedOutException) { + Log.w("AccountViewModel", "TimedOutException", e) + } catch (e: SignerExceptions.NothingToDecrypt) { + Log.w("AccountViewModel", "NothingToDecrypt", e) + } catch (e: SignerExceptions.CouldNotPerformException) { + Log.w("AccountViewModel", "CouldNotPerformException", e) + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + Log.w("AccountViewModel", "ManuallyUnauthorizedException", e) + } catch (e: SignerExceptions.AutomaticallyUnauthorizedException) { + Log.w("AccountViewModel", "AutomaticallyUnauthorizedException", e) + } catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) { + Log.w("AccountViewModel", "TimedOutRunningOnBackgroundWithoutAutomaticPermissionExceptionException", e) + } + } + } + + fun follow(community: AddressableNote) { + runIOCatching { account.follow(community) } } fun follow(channel: PublicChatChannel) { - viewModelScope.launch(Dispatchers.IO) { account.follow(channel) } + runIOCatching { account.follow(channel) } } fun follow(channel: EphemeralChatChannel) { - viewModelScope.launch(Dispatchers.IO) { account.follow(channel) } + runIOCatching { account.follow(channel) } + } + + fun unfollow(community: AddressableNote) { + runIOCatching { account.unfollow(community) } } fun unfollow(channel: PublicChatChannel) { - viewModelScope.launch(Dispatchers.IO) { account.unfollow(channel) } + runIOCatching { account.unfollow(channel) } } fun unfollow(channel: EphemeralChatChannel) { - viewModelScope.launch(Dispatchers.IO) { account.unfollow(channel) } + runIOCatching { account.unfollow(channel) } } fun follow(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.follow(user) } + runIOCatching { account.follow(user) } } fun unfollow(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.unfollow(user) } + runIOCatching { account.unfollow(user) } } fun followGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.followGeohash(tag) } + runIOCatching { account.followGeohash(tag) } } fun unfollowGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.unfollowGeohash(tag) } + runIOCatching { account.unfollowGeohash(tag) } } fun followHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.followHashtag(tag) } + runIOCatching { account.followHashtag(tag) } } fun unfollowHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.unfollowHashtag(tag) } + runIOCatching { account.unfollowHashtag(tag) } } fun showWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.showWord(word) } + runIOCatching { account.showWord(word) } } fun hideWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } + runIOCatching { account.hideWord(word) } } fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex @@ -888,20 +858,12 @@ class AccountViewModel( fun isFollowing(user: HexKey): Boolean = account.isFollowing(user) - fun updateContentWarnings(value: WarningType) { - viewModelScope.launch(Dispatchers.IO) { - account.updateShowSensitiveContent(value.prefCode) - } - } - fun markDonatedInThisVersion() = account.markDonatedInThisVersion() fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom fun dontTranslateFromFilteredBySpokenLanguages() = account.settings.syncedSettings.dontTranslateFromFilteredBySpokenLanguages() - fun toggleDontTranslateFrom(languageCode: String) = account.toggleDontTranslateFrom(languageCode) - fun translateTo() = account.settings.syncedSettings.languages.translateTo fun defaultZapType() = account.settings.syncedSettings.zaps.defaultZapType.value @@ -918,65 +880,62 @@ class AccountViewModel( fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers - fun updateWarnReports(warnReports: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - account.updateWarnReports(warnReports) - } - } + fun updateWarnReports(warnReports: Boolean) = runIOCatching { account.updateWarnReports(warnReports) } - fun updateFilterSpam(filterSpam: Boolean) { - viewModelScope.launch(Dispatchers.IO) { + fun updateFilterSpam(filterSpam: Boolean) = + runIOCatching { if (account.updateFilterSpam(filterSpam)) { LocalCache.antiSpam.active = filterSpamFromStrangers().value } } + + fun updateShowSensitiveContent(show: Boolean?) = runIOCatching { account.updateShowSensitiveContent(show) } + + fun changeReactionTypes( + reactionSet: List, + onDone: () -> Unit, + ) = runIOCatching { + account.changeReactionTypes(reactionSet) + onDone() } - fun unwrap( - event: GiftWrapEvent, - onReady: (Event) -> Unit, - ) { - account.unwrap(event, onReady) - } + fun updateZapAmounts( + amountSet: List, + selectedZapType: LnZapEvent.ZapType, + nip47Update: Nip47WalletConnect.Nip47URINorm?, + ) = runIOCatching { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } - fun unseal( - event: SealedRumorEvent, - onReady: (Event) -> Unit, - ) { - account.unseal(event, onReady) - } + fun toggleDontTranslateFrom(languageCode: String) = runIOCatching { account.toggleDontTranslateFrom(languageCode) } - fun show(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.showUser(user.pubkeyHex) } - } + fun updateTranslateTo(languageCode: Locale) = runIOCatching { account.updateTranslateTo(languageCode) } - fun hide(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.hideUser(user.pubkeyHex) } - } + fun prefer( + source: String, + target: String, + preference: String, + ) = runIOCatching { account.prefer(source, target, preference) } - fun hide(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } - } + fun show(user: User) = runIOCatching { account.showUser(user.pubkeyHex) } - fun showUser(pubkeyHex: String) { - viewModelScope.launch(Dispatchers.IO) { account.showUser(pubkeyHex) } - } + fun hide(user: User) = runIOCatching { account.hideUser(user.pubkeyHex) } - fun createStatus(newStatus: String) { - viewModelScope.launch(Dispatchers.IO) { account.createStatus(newStatus) } - } + fun hide(word: String) = runIOCatching { account.hideWord(word) } + + fun showUser(pubkeyHex: String) = runIOCatching { account.showUser(pubkeyHex) } + + fun createStatus(newStatus: String) = runIOCatching { account.createStatus(newStatus) } fun updateStatus( address: Address, newStatus: String, ) { - viewModelScope.launch(Dispatchers.IO) { + runIOCatching { account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus) } } fun deleteStatus(address: Address) { - viewModelScope.launch(Dispatchers.IO) { + runIOCatching { account.deleteStatus(LocalCache.getOrCreateAddressableNote(address)) } } @@ -1007,8 +966,8 @@ class AccountViewModel( Nip05NostrAddressVerifier() .verifyNip05( nip05, - okttpClient = { - app.okHttpClients.getHttpClient(account.shouldUseTorForNIP05(it)) + okHttpClient = { + app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForNIP05(it)) }, onSuccess = { // Marks user as verified @@ -1036,17 +995,15 @@ class AccountViewModel( } } - fun createRumor(template: EventTemplate) = account.signer.assembleRumor(template) - fun retrieveRelayDocument( - dirtyUrl: String, + relay: NormalizedRelayUrl, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { Nip11CachedRetriever.loadRelayInfo( - dirtyUrl, - okHttpClient = ::okHttpClientForDirty, + relay, + okHttpClient = { okHttpClientForClean(relay) }, onInfo, onError, ) @@ -1099,16 +1056,7 @@ class AccountViewModel( fun getNoteIfExists(hex: HexKey): Note? = LocalCache.getNoteIfExists(hex) - override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? = LocalCache.checkGetOrCreateAddressableNote(key) - - fun checkGetOrCreateAddressableNote( - key: HexKey, - onResult: (AddressableNote?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } - } - - suspend fun getOrCreateAddressableNote(key: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(key) + override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) fun getOrCreateAddressableNote( key: Address, @@ -1136,7 +1084,7 @@ class AccountViewModel( ) { onResult( withContext(Dispatchers.Default) { - LocalCache.findEarliestOtsForNote(note, account::otsResolver) + LocalCache.findEarliestOtsForNote(note, account.otsResolverBuilder) }, ) } @@ -1154,20 +1102,24 @@ class AccountViewModel( ) } - private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? = LocalCache.checkGetOrCreateChannel(key) + suspend fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel? = LocalCache.getOrCreatePublicChatChannel(key) - suspend fun checkGetOrCreateChannel(key: RoomId): Channel? = LocalCache.checkGetOrCreateChannel(key) + suspend fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel? = LocalCache.getOrCreateLiveChannel(key) + + suspend fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key) fun checkGetOrCreateChannel( key: HexKey, onResult: (Channel?) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateChannel(key)) } + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreatePublicChatChannel(key)) } } - fun getChannelIfExists(hex: HexKey): Channel? = LocalCache.getChannelIfExists(hex) + fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex) - fun getChannelIfExists(key: RoomId): Channel? = LocalCache.getChannelIfExists(key.toKey()) + fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key) + + fun getLiveActivityChannelIfExists(key: Address) = LocalCache.getLiveActivityChannelIfExists(key) fun loadParticipants( participants: List, @@ -1204,34 +1156,10 @@ class AccountViewModel( .sortedBy { account.isFollowing(it) } .reversed() - fun loadUsers( - event: GeneralListEvent, - onReady: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.Default) { - account.decryptPeopleList(event) { privateTagList -> - onReady( - (event.taggedUserIds() + event.filterUsers(privateTagList)) - .toSet() - .mapNotNull { hex -> checkGetOrCreateUser(hex) } - .sortedBy { account.isFollowing(it) } - .reversed() - .toImmutableList(), - ) - } + suspend fun checkVideoIsOnline(videoUrl: String): Boolean = + withContext(Dispatchers.IO) { + OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo) } - } - - fun checkVideoIsOnline( - videoUrl: String, - onDone: (Boolean) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onDone( - OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo), - ) - } - } fun loadAndMarkAsRead( routeForLastRead: String, @@ -1252,74 +1180,20 @@ class AccountViewModel( return onIsNew } - fun markAllAsRead( - notes: ImmutableList, - accountViewModel: AccountViewModel, - onDone: () -> Unit, - ) { + fun markAllChatNotesAsRead(notes: List) { viewModelScope.launch(Dispatchers.IO) { for (note in notes) { - note.event?.createdAt?.let { date -> - val route = routeFor(note, accountViewModel.account.userProfile()) - route?.let { - if (route is Route.Room) { - account.markAsRead("Room/${route.id}", date) - } else if (route is Route.Channel) { - account.markAsRead("Channel/${route.id}", date) - } - } + val noteEvent = note.event + if (noteEvent is IsInPublicChatChannel) { + account.markAsRead("Channel/${noteEvent.channelId()}", noteEvent.createdAt) + } else if (noteEvent is ChatroomKeyable) { + account.markAsRead("Room/${noteEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt) } } - - onDone() } } - fun createChatRoomFor( - user: User, - then: (Int) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) - account.userProfile().createChatroom(withKey) - then(withKey.hashCode()) - } - } - - fun setTorSettings(newTorSettings: TorSettings) = - viewModelScope.launch(Dispatchers.IO) { - // Only restart relay connections if port or type changes - if (account.settings.setTorSettings(newTorSettings)) { - app.serviceManager.forceRestart() - } - } - - fun forceRestartServices() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.setAccountAndRestart(account) - } - - fun justStart() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.justStartIfItHasAccount() - } - - fun justPause() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.cleanObservers() - app.serviceManager.pauseForGood() - } - - fun pauseAndLogOff() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.cleanObservers() - app.serviceManager.pauseAndLogOff() - } - - fun changeProxyPort(port: Int) = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.forceRestart() - } + fun setTorSettings(newTorSettings: TorSettings) = runIOCatching { account.settings.setTorSettings(newTorSettings) } class Factory( val accountSettings: AccountSettings, @@ -1330,39 +1204,97 @@ class AccountViewModel( override fun create(modelClass: Class): T = AccountViewModel(accountSettings, settings, app) as T } - private var collectorJob: Job? = null - private val bundlerInsert = BundledInsert>(3000, Dispatchers.Default) - init { Log.d("Init", "AccountViewModel") - collectorJob = - viewModelScope.launch(Dispatchers.Default) { - feedStates.init() - // awaits for init to finish before starting to capture new events. - LocalCache.live.newEventBundles.collect { newNotes -> - if (isDebug) { - Log.d( - "Rendering Metrics", - "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()} with ${newNotes.size} new notes", - ) - } - logTime("AccountViewModel newEventBundle Update with ${newNotes.size} new notes") { - feedStates.updateFeedsWith(newNotes) - upgradeAttestations() - precomputeNewEvents(newNotes) + viewModelScope.launch(Dispatchers.Default) { + feedStates.init() + // awaits for init to finish before starting to capture new events. + LocalCache.live.newEventBundles.collect { newNotes -> + if (isDebug) { + Log.d( + "Rendering Metrics", + "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()} with ${newNotes.size} new notes", + ) + } + logTime("AccountViewModel newEventBundle Update with ${newNotes.size} new notes") { + feedStates.updateFeedsWith(newNotes) + upgradeAttestations() + viewModelScope.launch(Dispatchers.Default) { + newNotesPreProcessor.runNew(newNotes) } } } + } + + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + if (isDebug) { + Log.d( + "Rendering Metrics", + "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()} with ${newNotes.size} new notes", + ) + } + logTime("AccountViewModel deletedEventBundle Update with ${newNotes.size} new notes") { + newNotesPreProcessor.runDeleted(newNotes) + } + } + } } override fun onCleared() { Log.d("Init", "AccountViewModel onCleared") feedStates.destroy() - bundlerInsert.cancel() - collectorJob?.cancel() super.onCleared() } + fun sendVoiceReply( + note: Note, + recording: RecordingResult, + context: Context, + ) { + if (isWriteable()) { + val hint = note.toEventHint() + if (hint == null) return + + runIOCatching { + val uploader = UploadOrchestrator() + val result = + uploader.upload( + uri = recording.file.toUri(), + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = account.settings.defaultFileServer, + account = account, + context = context, + ) + + if (result is UploadingState.Finished && result.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + account.sendVoiceReplyMessage( + result.result.url, + result.result.fileHeader.mimeType ?: recording.mimeType, + result.result.fileHeader.hash, + recording.duration, + recording.amplitudes, + hint, + ) + } else if (result is UploadingState.Error) { + toastManager.toast( + R.string.failed_to_upload_media_no_details, + result.errorResource, + *result.params, + ) + } + } + } else { + toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_reply, + ) + } + } + fun loadThumb( context: Context, thumbUri: String, @@ -1415,8 +1347,11 @@ class AccountViewModel( } if (isWriteable()) { - if (hasBoosted(baseNote)) { - deleteBoostsTo(baseNote) + val boosts = baseNote.boostedBy(userProfile()) + if (boosts.isNotEmpty()) { + runIOCatching { + account.delete(boosts) + } } else { onMore() } @@ -1436,15 +1371,26 @@ class AccountViewModel( val lud16 = account.userProfile().info?.lud16 if (lud16 != null) { viewModelScope.launch(Dispatchers.IO) { - CashuProcessor() - .melt( - token, - lud16, - okHttpClient = ::okHttpClientForMoney, - onSuccess = { title, message -> onDone(title, message) }, - onError = { title, message -> onDone(title, message) }, - context, + try { + val meltResult = MeltProcessor().melt(token, lud16, ::okHttpClientForMoney, context) + onDone( + stringRes(context, R.string.cashu_successful_redemption), + stringRes( + context, + R.string.cashu_successful_redemption_explainer, + token.totalAmount.toString(), + meltResult.fees.toString(), + ), ) + } catch (e: LightningAddressResolver.LightningAddressError) { + onDone(e.title, e.msg) + } catch (e: Exception) { + if (e is kotlin.coroutines.cancellation.CancellationException) throw e + onDone( + stringRes(context, R.string.cashu_failed_redemption), + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) + } } } else { onDone( @@ -1458,111 +1404,106 @@ class AccountViewModel( } } - fun unwrapIfNeeded( - event: Event?, - onReady: (Note) -> Unit, - ) { - when (event) { - is GiftWrapEvent -> { - event.innerEventId?.let { - val existingNote = LocalCache.getNoteIfExists(it) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - event.unwrap(account.signer) { - // clear the encrypted payload to save memory - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + private suspend fun unwrapGiftWrap(event: GiftWrapEvent): Note? { + val cacheInnerEventId = event.innerEventId + return if (cacheInnerEventId != null) { + val existingNoteEvent = LocalCache.getNoteIfExists(cacheInnerEventId)?.event + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + val newEvent = event.unwrapOrNull(account.signer) - if (LocalCache.justConsume(it, null, false)) { - unwrapIfNeeded(it, onReady) - } - } - } - } ?: run { - event.unwrap(account.signer) { - // clear the encrypted payload to save memory - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + if (newEvent == null) return null - val existingNote = LocalCache.getNoteIfExists(it.id) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - if (LocalCache.justConsume(it, null, false)) { - unwrapIfNeeded(it, onReady) - } - } - } - } + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + LocalCache.justConsume(newEvent, null, false) + + unwrapIfNeeded(newEvent) } - is SealedRumorEvent -> { - event.innerEventId?.let { - val existingNote = LocalCache.getNoteIfExists(it) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - event.unseal(account.signer) { - // clear the encrypted payload to save memory - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + } else { + val newEvent = event.unwrapThrowing(account.signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() - // this is not verifiable - if (LocalCache.justConsume(it, null, true)) { - unwrapIfNeeded(it, onReady) - } - } - } - } ?: run { - event.unseal(account.signer) { - // clear the encrypted payload to save memory - LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + val existingNoteEvent = LocalCache.getNoteIfExists(newEvent.id)?.event - val existingNote = LocalCache.getNoteIfExists(it.id) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - // this is not verifiable - if (LocalCache.justConsume(it, null, true)) { - unwrapIfNeeded(it, onReady) - } - } - } - } - } - else -> { - event?.id?.let { - LocalCache.getNoteIfExists(it)?.let { - onReady(it) - } - } + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + LocalCache.justConsume(newEvent, null, false) + unwrapIfNeeded(newEvent) } } } + private suspend fun unwrapSeal(event: SealedRumorEvent): Note? { + val cacheInnerEventId = event.innerEventId + return if (cacheInnerEventId != null) { + val existingNoteEvent = LocalCache.getNoteIfExists(cacheInnerEventId)?.event + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + val newEvent = event.unsealThrowing(account.signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + // this is not verifiable + LocalCache.justConsume(newEvent, null, true) + unwrapIfNeeded(newEvent) + } + } else { + val newEvent = event.unsealThrowing(account.signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + val existingNoteEvent = LocalCache.getNoteIfExists(newEvent.id)?.event + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + // this is not verifiable + LocalCache.justConsume(newEvent, null, true) + unwrapIfNeeded(newEvent) + } + } + } + + private suspend fun unwrapIfNeeded(event: Event): Note? = + when (event) { + is GiftWrapEvent -> unwrapGiftWrap(event) + is SealedRumorEvent -> unwrapSeal(event) + else -> LocalCache.getNoteIfExists(event.id) + } + fun unwrapIfNeeded( note: Note?, - onReady: (Note) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - unwrapIfNeeded(note?.event) { - onReady(it) + onReady: (Note) -> Unit = {}, + ) = runIOCatching { + val noteEvent = note?.event + if (noteEvent != null) { + val resultingNote = unwrapIfNeeded(noteEvent) + if (resultingNote != null && resultingNote != note) { + onReady(resultingNote) } } } - fun proxyPortFor(url: String): Int? = app.okHttpClients.getCurrentProxyPort(account.shouldUseTorForVideoDownload(url)) + fun proxyPortFor(url: String): Int? = app.okHttpClients.getCurrentProxyPort(account.privacyState.shouldUseTorForVideoDownload(url)) - fun okHttpClientForNip96(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(url)) + fun okHttpClientForNip96(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(url)) - fun okHttpClientForImage(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForImageDownload(url)) + fun okHttpClientForImage(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForImageDownload(url)) - fun okHttpClientForVideo(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForVideoDownload(url)) + fun okHttpClientForVideo(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForVideoDownload(url)) - fun okHttpClientForMoney(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForMoneyOperations(url)) + fun okHttpClientForMoney(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForMoneyOperations(url)) - fun okHttpClientForPreview(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForPreviewUrl(url)) + fun okHttpClientForPreview(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForPreviewUrl(url)) - fun okHttpClientForDirty(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForDirty(url)) + fun okHttpClientForClean(url: NormalizedRelayUrl): OkHttpClient = app.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(url)) - fun okHttpClientForTrustedRelays(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForTrustedRelays()) + fun okHttpClientForTrustedRelays(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForTrustedRelays()) fun dataSources() = app.sources @@ -1570,12 +1511,7 @@ class AccountViewModel( account.deleteDraft(draftTag) } - suspend fun createTempDraftNote( - noteEvent: DraftEvent, - onReady: (Note?) -> Unit, - ) { - draftNoteCache.update(noteEvent, onReady) - } + suspend fun createTempDraftNote(noteEvent: DraftEvent): Note? = draftNoteCache.update(noteEvent) fun createTempDraftNote( innerEvent: Event, @@ -1592,7 +1528,7 @@ class AccountViewModel( } fun requestDVMContentDiscovery( - dvmPublicKey: String, + dvmPublicKey: User, onReady: (event: Note) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { @@ -1627,12 +1563,11 @@ class AccountViewModel( fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, - onSent: () -> Unit, + onSent: () -> Unit = {}, onResponse: (Response?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - account.sendZapPaymentRequestFor(bolt11, zappedNote, onSent, onResponse) - } + ) = runIOCatching { + account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + onSent() } fun getRelayListFor(user: User): AdvertisedRelayListEvent? = (getRelayListNoteFor(user)?.event as? AdvertisedRelayListEvent?) @@ -1665,80 +1600,109 @@ class AccountViewModel( } fun sendSats( - lnaddress: String, + lnAddress: String, + user: User, milliSats: Long, message: String, - toUserPubKeyHex: HexKey, - onSuccess: (String) -> Unit, + onNewInvoice: (String) -> Unit, onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, ) { viewModelScope.launch(Dispatchers.IO) { - if (defaultZapType() == LnZapEvent.ZapType.NONZAP) { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress, - milliSats, - message, - null, + try { + val zapRequest = + if (defaultZapType() != LnZapEvent.ZapType.NONZAP) { + account.createZapRequestFor(user, message, defaultZapType()) + } else { + null + } + + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lnAddress, + milliSats = milliSats, + message = message, + nostrRequest = zapRequest, okHttpClient = ::okHttpClientForMoney, - onSuccess = onSuccess, - onError = onError, onProgress = onProgress, context = context, ) - } else { - account.createZapRequestFor(toUserPubKeyHex, message, defaultZapType()) { zapRequest -> - LocalCache.justConsumeMyOwnEvent(zapRequest) - LightningAddressResolver() - .lnAddressInvoice( - lnaddress, - milliSats, - message, - zapRequest.toJson(), - okHttpClient = ::okHttpClientForMoney, - onSuccess = onSuccess, - onError = onError, - onProgress = onProgress, - context = context, - ) - } + + onNewInvoice(invoice) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg) + } catch (e: Exception) { + if (e is CancellationException) throw e + onError("Error", e.message ?: "Unknown error") } } } + fun saveMediaToGallery( + videoUri: String?, + mimeType: String?, + localContext: Context, + ) { + viewModelScope.launch { + MediaSaverToDisk.saveDownloadingIfNeeded( + videoUri = videoUri, + okHttpClient = ::okHttpClientForVideo, + mimeType = mimeType, + localContext = localContext, + onSuccess = { + toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery) + }, + onError = { + toastManager.toast(R.string.failed_to_save_the_video, null, it) + }, + ) + } + } + suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) fun relayStatusFlow() = app.client.relayStatusFlow() - fun allAccountsSync(): List = - runBlocking { - LocalPreferences.allSavedAccounts().mapNotNull { + fun convertAccounts(loggedInAccounts: List?): Set = + loggedInAccounts + ?.mapNotNull { try { it.npub.bechToBytes().toHexKey() } catch (e: Exception) { if (e is CancellationException) throw e null } - } - } + }?.toSet() ?: emptySet() + + val trustedAccounts: StateFlow> = + LocalPreferences + .accountsFlow() + .map { loggedInAccounts -> + convertAccounts(loggedInAccounts) + }.onStart { + emit(convertAccounts(LocalPreferences.allSavedAccounts())) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptySet(), + ) val draftNoteCache = CachedDraftNotes(this) class CachedDraftNotes( val accountViewModel: AccountViewModel, ) : GenericBaseCacheAsync(20) { - override suspend fun compute( - key: DraftEvent, - onReady: (Note?) -> Unit, - ) = withContext(Dispatchers.IO) { - key.cachedDraft(accountViewModel.account.signer) { - val author = LocalCache.getOrCreateUser(key.pubKey) - val note = accountViewModel.createTempDraftNote(it, author) - onReady(note) + override suspend fun compute(key: DraftEvent): Note? = + withContext(Dispatchers.IO) { + val decrypted = accountViewModel.account.draftsDecryptionCache.cachedDraft(key) + if (decrypted != null) { + val author = LocalCache.getOrCreateUser(key.pubKey) + accountViewModel.createTempDraftNote(decrypted, author) + } else { + null + } } - } } val bechLinkCache = CachedLoadedBechLink(this) @@ -1755,7 +1719,7 @@ class AccountViewModel( is NSec -> {} is NPub -> {} is NProfile -> {} - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> { + is NNote -> { LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } @@ -1797,9 +1761,13 @@ class AccountViewModel( val nip19: Nip19Parser.ParseReturn, ) +var mockedCache: AccountViewModel? = null + @SuppressLint("ViewModelConstructorInComposable") @Composable fun mockAccountViewModel(): AccountViewModel { + mockedCache?.let { return it } + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() sharedPreferencesViewModel.init() @@ -1815,12 +1783,18 @@ fun mockAccountViewModel(): AccountViewModel { ), sharedPreferencesViewModel.sharedPrefs, Amethyst(), - ) + ).also { + mockedCache = it + } } +var vitorCache: AccountViewModel? = null + @SuppressLint("ViewModelConstructorInComposable") @Composable fun mockVitorAccountViewModel(): AccountViewModel { + mockedCache?.let { return it } + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() sharedPreferencesViewModel.init() @@ -1834,5 +1808,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { ), sharedPreferencesViewModel.sharedPrefs, Amethyst(), - ) + ).also { + vitorCache = it + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt new file mode 100644 index 0000000000..b7ef28cb12 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -0,0 +1,331 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.util.Log +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CancellationException + +class DecryptAndIndexProcessor( + val account: Account, + val cache: LocalCache, +) { + suspend fun consume(note: Note) { + val noteEvent = note.event + if (noteEvent != null) { + try { + consumeAlreadyVerified(noteEvent, note, note) + } catch (e: Exception) { + Log.e("PrecacheNewNotesProcessor", "Error processing note", e) + } + } + } + + suspend fun consumeAlreadyVerified( + event: Event, + eventNote: Note, + publicNote: Note, + ) { + when (event) { + is PrivateDmEvent -> { + if (event.canDecrypt(account.signer.pubKey)) { + val talkingWith = event.chatroomKey(account.signer.pubKey) + account.chatroomList.addMessage(talkingWith, eventNote) + } + } + + is ChatMessageEvent -> { + if (event.isIncluded(account.signer.pubKey)) { + val key = event.chatroomKey(account.signer.pubKey) + account.chatroomList.addMessage(key, eventNote) + } + } + + is ChatMessageEncryptedFileHeaderEvent -> { + if (event.isIncluded(account.signer.pubKey)) { + val key = event.chatroomKey(account.signer.pubKey) + account.chatroomList.addMessage(key, eventNote) + } + } + + is OtsEvent -> { + // verifies new OTS upon arrival + Amethyst.instance.otsVerifCache.cacheVerify(event, account.otsResolverBuilder) + } + + is DraftEvent -> { + // Avoid decrypting over and over again if the event already exist. + if (event.pubKey == account.signer.pubKey) { + if (!event.isDeleted()) { + val rumor = account.draftsDecryptionCache.preCachedDraft(event) ?: account.draftsDecryptionCache.cachedDraft(event) + if (rumor != null) { + indexDraftAsRealEvent(eventNote, rumor) + } + } + } + } + + is GiftWrapEvent -> { + if (event.recipientPubKey() == account.signer.pubKey) { + val innerGiftId = event.innerEventId + if (innerGiftId == null) { + event.unwrapOrNull(account.signer)?.let { innerGift -> + // clear the encrypted payload to save memory + eventNote.event = event.copyNoContent() + if (cache.justConsume(innerGift, null, false)) { + cache.copyRelaysFromTo(publicNote, innerGift) + val innerGiftNote = cache.getOrCreateNote(innerGift.id) + consumeAlreadyVerified(innerGift, innerGiftNote, publicNote) + } + } + } else { + cache.copyRelaysFromTo(publicNote, innerGiftId) + + val innerGiftNote = cache.getOrCreateNote(innerGiftId) + val innerGift = innerGiftNote.event + if (innerGift != null) { + consumeAlreadyVerified(innerGift, innerGiftNote, publicNote) + } + } + } + } + + is SealedRumorEvent -> { + val rumorId = event.innerEventId + if (rumorId == null) { + event.unsealOrNull(account.signer)?.let { innerRumor -> + // clear the encrypted payload to save memory + eventNote.event = event.copyNoContent() + + // rumors cannot be verified + cache.justConsume(innerRumor, null, true) + + cache.copyRelaysFromTo(publicNote, innerRumor) + val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + consumeAlreadyVerified(innerRumor, innerRumorNote, publicNote) + } + } else { + cache.copyRelaysFromTo(publicNote, rumorId) + + val innerRumorNote = cache.getOrCreateNote(rumorId) + val innerRumor = innerRumorNote.event + if (innerRumor != null) { + consumeAlreadyVerified(innerRumor, innerRumorNote, publicNote) + } + } + } + + is LnZapRequestEvent -> { + if (account.privateZapsDecryptionCache.cachedPrivateZap(event) == null && event.isPrivateZap()) { + account.privateZapsDecryptionCache.decryptPrivateZap(event) + } + } + } + } + + suspend fun delete(note: Note) { + val noteEvent = note.event + if (noteEvent != null) { + try { + delete(noteEvent, note) + } catch (e: Exception) { + Log.e("PrecacheNewNotesProcessor", "Error processing note", e) + } + } + } + + suspend fun delete( + event: Event, + eventNote: Note, + ) { + when (event) { + is PrivateDmEvent -> { + if (event.canDecrypt(account.signer.pubKey)) { + // Avoid decrypting over and over again if the event already exist. + val talkingWith = event.chatroomKey(account.signer.pubKey) + account.chatroomList.removeMessage(talkingWith, eventNote) + } + } + + is ChatMessageEvent -> { + if (event.isIncluded(account.signer.pubKey)) { + val key = event.chatroomKey(account.signer.pubKey) + account.chatroomList.removeMessage(key, eventNote) + } + } + + is ChatMessageEncryptedFileHeaderEvent -> { + if (event.isIncluded(account.signer.pubKey)) { + val key = event.chatroomKey(account.signer.pubKey) + account.chatroomList.removeMessage(key, eventNote) + } + } + + is DraftEvent -> { + // Avoid decrypting over and over again if the event already exist. + if (event.pubKey == account.signer.pubKey) { + // already deindexed by LocalCache + // deindexDraftAsRealEvent(eventNote) + } + } + + is GiftWrapEvent -> { + if (event.recipientPubKey() == account.signer.pubKey) { + val innerGiftId = event.innerEventId + if (innerGiftId != null) { + val innerGiftNote = cache.getNoteIfExists(innerGiftId) + val innerGift = innerGiftNote?.event + if (innerGift != null) { + delete(innerGift, innerGiftNote) + } + } + } + } + + is SealedRumorEvent -> { + val rumorId = event.innerEventId + if (rumorId != null) { + val innerRumorNote = cache.getNoteIfExists(rumorId) + val innerRumor = innerRumorNote?.event + if (innerRumor != null) { + delete(innerRumor, innerRumorNote) + } + } + } + + is LnZapEvent -> { + event.zapRequest?.let { req -> + if (req.isPrivateZap()) { + // We can't know which account this was for without going through it. + account.privateZapsDecryptionCache.delete(req) + } + } + } + + is LnZapRequestEvent -> { + if (event.isPrivateZap()) { + account.privateZapsDecryptionCache.delete(event) + } + } + // .. + } + } + + fun indexDraftAsRealEvent( + draftEventWrap: Note, + rumor: Event, + ) { + draftEventWrap.replyTo = cache.computeReplyTo(rumor) + draftEventWrap.replyTo?.forEach { it.addReply(draftEventWrap) } + + when (rumor) { + is PrivateDmEvent -> { + if (rumor.canDecrypt(account.signer)) { + val talkingWith = rumor.chatroomKey(account.signer.pubKey) + account.chatroomList.addMessage(talkingWith, draftEventWrap) + } + } + is ChatMessageEvent -> { + if (rumor.isIncluded(account.signer.pubKey)) { + val key = rumor.chatroomKey(account.signer.pubKey) + account.chatroomList.addMessage(key, draftEventWrap) + } + } + is ChatMessageEncryptedFileHeaderEvent -> { + if (rumor.isIncluded(account.signer.pubKey)) { + val key = rumor.chatroomKey(account.signer.pubKey) + account.chatroomList.addMessage(key, draftEventWrap) + } + } + is EphemeralChatEvent -> { + rumor.roomId()?.let { + val channel = cache.getOrCreateEphemeralChannel(it) + channel.addNote(draftEventWrap, null) + } + } + is ChannelMessageEvent -> { + rumor.channelId()?.let { channelId -> + val channel = cache.checkGetOrCreatePublicChatChannel(channelId) + channel?.addNote(draftEventWrap, null) + } + } + is LiveActivitiesChatMessageEvent -> { + rumor.activityAddress()?.let { channelId -> + val channel = cache.getOrCreateLiveChannel(channelId) + channel.addNote(draftEventWrap, null) + } + } + } + } + + suspend fun runNew(newNotes: Set) { + try { + newNotes.forEach { + consume(it) + } + + val toDelete = + newNotes.mapNotNull { + val noteEvent = it.event + if (noteEvent is DraftEvent && noteEvent.isDeleted()) { + it + } else { + null + } + } + + if (toDelete.isNotEmpty()) { + Log.w("PrecacheNewNotesProcessor", "Deleting ${toDelete.size} draft notes that should have been deleted already") + account.delete(toDelete) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("PrecacheNewNotesProcessor", "This shouldn't happen", e) + } + } + + suspend fun runDeleted(newNotes: Set) { + try { + newNotes.forEach { + delete(it) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("PrecacheNewNotesProcessor", "This shouldn't happen", e) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index c40608f6ca..b5e831e9bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,10 +32,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst @@ -42,24 +41,17 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinatorSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.AppNavigation -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus -import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal -import kotlinx.coroutines.CancellationException +import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -88,6 +80,9 @@ fun LoggedInPage( // Sets up Coil's Image Loader ObserveImageLoadingTor(accountViewModel) + // Sets up the use of Proxy based on this Account's settings + SetProxyDeterminator(accountViewModel) + // Loads account information + DMs and Notifications from Relays. AccountFilterAssemblerSubscription(accountViewModel) @@ -97,16 +92,11 @@ fun LoggedInPage( VideoFilterAssemblerSubscription(accountViewModel) DiscoveryFilterAssemblerSubscription(accountViewModel) - // TODO: Is this needed? - RelaySubscriptionsCoordinatorSubscription(accountViewModel) - // Updates local cache of the anti-spam filter choice of this user. ObserveAntiSpamFilterSettings(accountViewModel) - ManageRelayServices(accountViewModel, sharedPreferencesViewModel) - - // Turns Embed Tor on if needed. - ManageTorInstance(accountViewModel) + // Pauses relay services when the app pauses + ManageRelayServices(accountViewModel) // Listens to Amber ListenToExternalSignerIfNeeded(accountViewModel) @@ -130,65 +120,25 @@ fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) { } @Composable -fun ObserveImageLoadingTor(accountViewModel: AccountViewModel) { - LaunchedEffect(Unit) { - Amethyst.instance.setImageLoader(accountViewModel.account::shouldUseTorForImageDownload) +fun SetProxyDeterminator(accountViewModel: AccountViewModel) { + LaunchedEffect(accountViewModel) { + Amethyst.instance.torProxySettingsAnchor.flow + .tryEmit(accountViewModel.account.torRelayState.flow) } } @Composable -fun ManageRelayServices( - accountViewModel: AccountViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, -) { - LaunchedEffect( - sharedPreferencesViewModel.sharedPrefs.currentNetworkId, - sharedPreferencesViewModel.sharedPrefs.isOnMobileOrMeteredConnection, - ) { - Log.d("ManageRelayServices", "Loading/Change Network Id/State ${sharedPreferencesViewModel.sharedPrefs.currentNetworkId}, forcing start/restart of the relay services") - accountViewModel.forceRestartServices() +fun ObserveImageLoadingTor(accountViewModel: AccountViewModel) { + LaunchedEffect(accountViewModel) { + Amethyst.instance.setImageLoader(accountViewModel.account.privacyState::shouldUseTorForImageDownload) } +} - val lifeCycleOwner = LocalLifecycleOwner.current - - val scope = rememberCoroutineScope() - var job = remember { null } - - Log.d("ManageRelayServices", "Job $job for $accountViewModel") - - DisposableEffect(key1 = accountViewModel) { - job?.cancel() - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - job?.cancel() - Log.d("ManageRelayServices", "Resuming Relay Services $accountViewModel") - job = accountViewModel.justStart() - } - Lifecycle.Event.ON_PAUSE -> { - Log.d("ManageRelayServices", "Prepare to pause Relay Services $accountViewModel") - job?.cancel() - job = - scope.launch { - delay(30000) // 30 seconds - Log.d("ManageRelayServices", "Pausing Relay Services $accountViewModel") - accountViewModel.justPause() - } - } - else -> {} - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - job?.cancel() - lifeCycleOwner.lifecycle.removeObserver(observer) - Log.d("ManageRelayServices", "Disposing Relay Services $accountViewModel") - // immediately stops upon disposal - accountViewModel.pauseAndLogOff() - } - } +@Composable +fun ManageRelayServices(accountViewModel: AccountViewModel) { + val relayServices by Amethyst.instance.relayProxyClientConnector.relayServices + .collectAsStateWithLifecycle() + Log.d("ManageRelayServices", "Relay Services changed $relayServices") } @Composable @@ -201,7 +151,10 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { job?.cancel() job = scope.launch { - PushNotificationUtils.checkAndInit(LocalPreferences.allSavedAccounts(), accountViewModel::okHttpClientForTrustedRelays) + PushNotificationUtils.checkAndInit( + LocalPreferences.allSavedAccounts(), + accountViewModel::okHttpClientForTrustedRelays, + ) } onPauseOrDispose { @@ -210,96 +163,39 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { } } -@Composable -fun ManageTorInstance(accountViewModel: AccountViewModel) { - val torSettings by accountViewModel.account.settings.torSettings.torType - .collectAsStateWithLifecycle() - if (torSettings == TorType.INTERNAL) { - WatchTorConnection(accountViewModel) - } -} - -@Composable -fun WatchTorConnection(accountViewModel: AccountViewModel) { - val status by Amethyst.instance.torManager.status - .collectAsStateWithLifecycle() - - if (status is TorServiceStatus.Active) { - LaunchedEffect(key1 = status, key2 = accountViewModel) { - Log.d("TorService", "Tor has just finished connecting, force restart relays $accountViewModel") - accountViewModel.changeProxyPort((status as TorServiceStatus.Active).port) - } - } -} - @Composable private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { - if (accountViewModel.account.signer is NostrSignerExternal) { - val activity = getActivity() as MainActivity - - val lifeCycleOwner = LocalLifecycleOwner.current + if (accountViewModel.account.signer is IActivityLauncher) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - accountViewModel.toastManager.toast( - R.string.sign_request_rejected, - R.string.sign_request_rejected_description, - ) - } else { + if (result.resultCode == Activity.RESULT_OK) { result.data?.let { accountViewModel.runOnIO { - accountViewModel.account.signer.launcher - .newResult(it) + accountViewModel.account.signer.newResponse(it) } } } }, ) - DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity, lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toastManager.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, - ) - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } + DisposableEffect(accountViewModel, accountViewModel.account, launcher) { + val launcher: (Intent) -> Unit = { intent -> + try { + launcher.launch(intent) + } catch (e: ActivityNotFoundException) { + accountViewModel.toastManager.toast( + R.string.error_opening_external_signer, + R.string.error_opening_external_signer_description, + ) + throw e } + } - lifeCycleOwner.lifecycle.addObserver(observer) - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toastManager.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, - ) - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) + accountViewModel.account.signer.registerForegroundLauncher(launcher) onDispose { - accountViewModel.account.signer.launcher - .clearLauncher() - lifeCycleOwner.lifecycle.removeObserver(observer) + accountViewModel.account.signer.unregisterForegroundLauncher(launcher) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt deleted file mode 100644 index 1418caa61b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn - -import android.util.Log -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import kotlinx.coroutines.CancellationException - -class PrecacheNewNotesProcessor( - val account: Account, - val cache: LocalCache, -) { - fun consume(note: Note) { - val noteEvent = note.event - if (noteEvent != null) { - try { - consumeAlreadyVerified(noteEvent, note) - } catch (e: Exception) { - Log.e("PrecacheNewNotesProcessor", "Error processing note", e) - } - } - } - - fun consumeAlreadyVerified( - event: Event, - publicNote: Note, - ) { - when (event) { - is OtsEvent -> { - // verifies new OTS upon arrival - Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver) - } - - is PrivateOutboxRelayListEvent -> { - if (event.cachedPrivateTags() == null && event.pubKey == account.signer.pubKey) { - event.privateTags(account.signer) { } - } - } - - is DraftEvent -> { - // Avoid decrypting over and over again if the event already exist. - if (!event.isDeleted() && event.preCachedDraft(account.signer) == null && event.pubKey == account.signer.pubKey) { - event.cachedDraft(account.signer) { - cache.indexDraftAsRealEvent(event, it) - } - } - } - - is GiftWrapEvent -> { - if (event.recipientPubKey() == account.signer.pubKey) { - val inner = event.innerEventId - if (inner == null) { - event.unwrap(account.signer) { - // clear the encrypted payload to save memory - cache.getOrCreateNote(event.id).event = event.copyNoContent() - if (cache.justConsume(it, null, false)) { - cache.copyRelaysFromTo(publicNote, it) - consumeAlreadyVerified(it, publicNote) - } - } - } else { - cache.copyRelaysFromTo(publicNote, inner) - val event = cache.getOrCreateNote(inner).event - if (event != null) { - consumeAlreadyVerified(event, publicNote) - } - } - } - } - - is SealedRumorEvent -> { - val inner = event.innerEventId - if (inner == null) { - event.unseal(account.signer) { - // clear the encrypted payload to save memory - cache.getOrCreateNote(event.id).event = event.copyNoContent() - - // avoid verifying these events. - cache.justConsume(it, null, true) - cache.copyRelaysFromTo(publicNote, it) - } - } else { - cache.copyRelaysFromTo(publicNote, inner) - } - } - - is LnZapEvent -> { - event.zapRequest?.let { req -> - if (req.cachedPrivateZap() == null && req.isPrivateZap()) { - // We can't know which account this was for without going through it. - req.decryptPrivateZap(account.signer) {} - } - } - } - - is LnZapRequestEvent -> { - if (event.cachedPrivateZap() == null && event.isPrivateZap()) { - event.decryptPrivateZap(account.signer) { } - } - } - } - } - - fun run(newNotes: Set) { - try { - newNotes.forEach { - consume(it) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("PrecacheNewNotesProcessor", "This shouldn't happen", e) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt index 2ea557319d..ed958beadb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt @@ -40,8 +40,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPrivateFeedViewModel @@ -67,9 +67,10 @@ fun BookmarkListScreen( factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) - val userState by accountViewModel.account.decryptBookmarks.collectAsStateWithLifecycle(null) + val bookmarkState by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle(null) - LaunchedEffect(userState) { + LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt index 6aa7fdd261..85cdc1d776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt @@ -21,31 +21,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.FeedFilter class BookmarkPrivateFeedFilter( val account: Account, ) : FeedFilter() { - override fun feedKey(): String = account.userProfile().latestBookmarkList?.id ?: "" + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList - - if (!account.isWriteable()) return emptyList() - - val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList() - - return privateTags - .mapNotNull { - if (it.size > 1 && it[0] == "e") { - LocalCache.checkGetOrCreateNote(it[1]) - } else if (it.size > 1 && it[0] == "a") { - LocalCache.checkGetOrCreateAddressableNote(it[1]) - } else { - null - } - }.reversed() - } + override fun feed(): List = account.bookmarkState.bookmarks.value.private } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt index 3b2925007b..34a3962fb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt @@ -21,28 +21,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.FeedFilter class BookmarkPublicFeedFilter( val account: Account, ) : FeedFilter() { - override fun feedKey(): String = account.userProfile().latestBookmarkList?.id ?: "" + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList - - return bookmarks - ?.tags - ?.mapNotNull { - if (it.size > 1 && it[0] == "e") { - LocalCache.checkGetOrCreateNote(it[1]) - } else if (it.size > 1 && it[0] == "a") { - LocalCache.checkGetOrCreateAddressableNote(it[1]) - } else { - null - } - }?.reversed() ?: emptyList() - } + override fun feed(): List = account.bookmarkState.bookmarks.value.public } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 9961616620..b27f5ed3de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e0a9b04050..b92c80cf40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -42,12 +42,11 @@ import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.DisplayDraftChat import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu @@ -60,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW -import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote @@ -99,29 +97,25 @@ fun ChatroomMessageCompose( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, ) { - logTime( - debugMessage = { "ChatroomMessageCompose " + externalLinkForNote(baseNote) }, - ) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) { - WatchBlockAndReport( - note = baseNote, - showHiddenWarning = false, - modifier = Modifier.fillMaxWidth(), - accountViewModel = accountViewModel, - nav = nav, - ) { canPreview -> - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - onWantsToEditDraft, - ) - } + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) { + WatchBlockAndReport( + note = baseNote, + showHiddenWarning = false, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) { canPreview -> + NormalChatNote( + baseNote, + routeForLastRead, + innerQuote, + canPreview, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + onWantsToEditDraft, + ) } } } @@ -176,7 +170,7 @@ fun NormalChatNote( parentBackgroundColor = parentBackgroundColor, onClick = { if (note.event is ChannelCreateEvent) { - nav.nav(Route.Channel(note.idHex)) + nav.nav(Route.PublicChatChannel(note.idHex)) true } else { false @@ -233,7 +227,7 @@ fun NormalChatNote( val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val pow = remember(note) { note.event?.strongPoWOrNull() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 6d54512b8f..64352fb5e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -28,7 +28,7 @@ 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.CreateTextWithEmoji -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.FollowingIcon import com.vitorpamplona.amethyst.ui.note.InnerUserPicture import com.vitorpamplona.amethyst.ui.note.WatchUserFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt index 817ca2b0f7..73753a70b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 0dfaa6f9a7..dd7f65e173 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -37,7 +37,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,18 +48,18 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -68,14 +67,14 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm @Composable fun RenderCreateChannelNote( @@ -110,11 +109,11 @@ fun RenderChannelDataPreview() { id = "bbaacc", uri = "nostr:nevent1...", channelInfo = - ChannelData( + ChannelDataNorm( "My Group", "Testing About me", "http://test.com", - listOf("wss://nostr.mom", "wss://nos.lol"), + listOf(Constants.mom, Constants.nos), ), tags = EmptyTagList, bgColor = remember { mutableStateOf(Color.Transparent) }, @@ -128,7 +127,7 @@ fun RenderChannelDataPreview() { fun RenderChannelData( id: HexKey, uri: String, - channelInfo: ChannelData, + channelInfo: ChannelDataNorm, tags: ImmutableListOfLists, bgColor: MutableState, accountViewModel: AccountViewModel, @@ -233,73 +232,27 @@ fun RenderRelayLinePreview() { @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderRelayLinePublicChat( - dirtyUrl: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") - val relayInfo by loadRelayInfo(dirtyUrl, accountViewModel) - - var openRelayDialog by remember { mutableStateOf(false) } - - val info = - remember(dirtyUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(dirtyUrl)) - } - - if (openRelayDialog) { - relayInfo?.let { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = it, - relayBriefInfo = info, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + val relayInfo by loadRelayInfo(relay, accountViewModel) val clipboardManager = LocalClipboardManager.current val clickableModifier = - remember(dirtyUrl) { + remember(relay) { Modifier.combinedClickable( onLongClick = { - clipboardManager.setText(AnnotatedString(dirtyUrl)) - }, - onClick = { - accountViewModel.retrieveRelayDocument( - dirtyUrl, - onInfo = { - openRelayDialog = true - }, - onError = { url, errorCode, exceptionMessage -> - accountViewModel.toastManager.toast( - R.string.unable_to_download_relay_document, - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - R.string.relay_information_document_error_failed_to_assemble_url - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - R.string.relay_information_document_error_failed_to_reach_server - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - R.string.relay_information_document_error_failed_to_parse_response - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - R.string.relay_information_document_error_failed_with_http - }, - url, - exceptionMessage ?: errorCode.toString(), - ) - }, - ) + clipboardManager.setText(AnnotatedString(relay.url)) }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) } RenderRelayLine( - info.displayUrl, - relayInfo?.icon ?: info.favIcon, + relay.displayUrl(), + relayInfo.icon, clickableModifier, showPicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt index a8ddd36515..99a5d2031d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.NoteRow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index a708517904..188edfcaef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index 457c9e50e5..5bd75f486a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt index 7e4af311da..e29488fd20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index fd8c922f83..28a4156c42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -21,79 +21,37 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar -import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun ChatroomScreen( - roomId: String?, + roomId: ChatroomKey, draftMessage: String? = null, replyToNote: HexKey? = null, editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { - if (roomId == null) return - DisappearingScaffold( isInvertedLayout = true, topBar = { - RoomTopBar(roomId, accountViewModel, nav) + RenderRoomTopBar(roomId, accountViewModel, nav) }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { - Chatroom(roomId, draftMessage, replyToNote, editFromDraft, accountViewModel, nav) - } - } -} - -@Composable -private fun RoomTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadRoom(roomId = id, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav) - } else { - Spacer(BottomTopHeight) - } - } -} - -@Composable -fun Chatroom( - roomId: String?, - draftMessage: String? = null, - replyToNote: HexKey? = null, - editFromDraft: HexKey? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (roomId == null) return - - LoadRoom(roomId, accountViewModel) { - it?.let { ChatroomView( - room = it, + room = roomId, draftMessage = draftMessage, replyToNote = replyToNote, editFromDraft = editFromDraft, @@ -103,28 +61,3 @@ fun Chatroom( } } } - -@Composable -fun LoadRoom( - roomId: String, - accountViewModel: AccountViewModel, - content: @Composable (ChatroomKey?) -> Unit, -) { - var room by remember(roomId) { mutableStateOf(null) } - - if (room == null) { - LaunchedEffect(key1 = roomId) { - launch(Dispatchers.IO) { - val newRoom = - accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { - it.hashCode().toString() == roomId - } - if (room != newRoom) { - room = newRoom - } - } - } - } - - content(room) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index cf3e1f83c0..a5f198c9ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt index 74f4217b28..a7f7444e8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt @@ -27,8 +27,8 @@ import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.theme.IncognitoIconModifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group @@ -37,17 +37,17 @@ import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group fun IncognitoBadge(baseNote: Note) { if (baseNote.event is NIP17Group) { Icon( - painter = painterRes(id = R.drawable.incognito), + painter = painterRes(resourceId = R.drawable.incognito, 1), null, - modifier = incognitoIconModifier, + modifier = IncognitoIconModifier, tint = MaterialTheme.colorScheme.placeholderText, ) Spacer(modifier = StdHorzSpacer) } else if (baseNote.event is PrivateDmEvent) { Icon( - painter = painterRes(id = R.drawable.incognito_off), + painter = painterRes(resourceId = R.drawable.incognito_off, 1), null, - modifier = incognitoIconModifier, + modifier = IncognitoIconModifier, tint = MaterialTheme.colorScheme.placeholderText, ) Spacer(modifier = StdHorzSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt index 5f4fcf3a7d..3f24867d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt @@ -34,18 +34,18 @@ class ChatroomFeedFilter( override fun feedKey(): String = withUser.hashCode().toString() override fun feed(): List { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList() + val chatroom = account.chatroomList.getOrCreatePrivateChatroom(withUser) - return messages.roomMessages + return chatroom.messages .filter { account.isAcceptable(it) } .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } override fun applyFilter(collection: Set): Set { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptySet() + val chatroom = account.chatroomList.getOrCreatePrivateChatroom(withUser) - return collection.filter { it in messages.roomMessages && account.isAcceptable(it) }.toSet() + return collection.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() } override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 8d54bdbe12..740d4cddcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey // This allows multiple screen to be listening to tags, even the same tag @@ -41,15 +41,11 @@ class ChatroomFilterAssembler( ChatroomFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt index e10f139e11..bfa91e4d8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt @@ -21,23 +21,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient class ChatroomFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomQueryState, - since: Map?, - ): List? = - filterNip04DMs( - key.room.users, - key.account.userProfile().pubkeyHex, - since, - ) + since: SincePerRelayMap?, + ) = filterNip04DMs(key.room.users, key.account, since) override fun user(key: ChatroomQueryState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index 3d67904609..7fac8d0bc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -20,41 +20,73 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import kotlin.collections.toList +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent fun filterNip04DMs( group: Set?, - user: HexKey?, - since: Map?, -): List? { - if (group == null || group.isEmpty() || user == null || user.isEmpty()) return null + account: Account?, + since: SincePerRelayMap?, +): List? { + if (group == null || group.isEmpty() || account == null) return null - return listOf( - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), + val userOutboxRelays = account.outboxRelays.flow.value + val userInboxRelays = account.dmRelays.flow.value + + val groupOutboxRelays = mutableSetOf() + val groupInboxRelays = mutableSetOf() + + group.forEach { + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + val outbox = + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + + groupOutboxRelays.addAll(outbox) + + val inbox = + authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + + groupInboxRelays.addAll(inbox) + } + + val toMeRelays = (userInboxRelays + groupOutboxRelays) + val fromMeRelays = (userOutboxRelays + groupInboxRelays) + + return toMeRelays.map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(PrivateDmEvent.KIND), authors = group.toList(), - tags = mapOf("p" to listOf(user)), - since = since, + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since?.get(it)?.time, ), - ), - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(user), - tags = mapOf("p" to group.toList()), - since = since, - ), - ), - ) + ) + } + + fromMeRelays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to group.toList()), + since = since?.get(it)?.time, + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 5b6ce89bf4..15f12dc00a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -56,8 +56,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun NewChatroomSubjectDialog( @@ -75,7 +73,12 @@ fun NewChatroomSubjectDialog( Surface { val groupName = remember { - mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") + mutableStateOf( + accountViewModel.account.chatroomList.rooms + .get(room) + ?.subject + ?.value ?: "", + ) } val message = remember { mutableStateOf("") } val scope = rememberCoroutineScope() @@ -95,7 +98,7 @@ fun NewChatroomSubjectDialog( PostButton( onPost = { - scope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { val template = ChatMessageEvent.build( message.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index c51938ba6c..d412f75c12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -47,8 +47,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.UserCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt index 87aa3fff27..e5f0a008f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt @@ -28,8 +28,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRoomSubject import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserShortName import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -47,7 +47,11 @@ fun RoomNameOnlyDisplay( fontWeight: FontWeight = FontWeight.Bold, accountViewModel: AccountViewModel, ) { - val roomSubject by observeUserRoomSubject(accountViewModel.userProfile(), room, accountViewModel) + // Subscribe in the LocalCache for changes that arrive in the device + val roomSubject by accountViewModel.account.chatroomList + .getOrCreatePrivateChatroom(room) + .subject + .collectAsStateWithLifecycle() CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) { if (!it.isNullOrBlank()) { @@ -98,7 +102,10 @@ fun RoomNameDisplay( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val roomSubject by observeUserRoomSubject(accountViewModel.userProfile(), room, accountViewModel) + val roomSubject by accountViewModel.account.chatroomList + .getOrCreatePrivateChatroom(room) + .subject + .collectAsStateWithLifecycle() CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) { if (!it.isNullOrBlank()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index e20c597ecb..6d713786ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState @@ -105,6 +104,9 @@ class ChatNewMessageViewModel : IZapRaiser { val draftTag = DraftTagState() + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { @@ -116,8 +118,6 @@ class ChatNewMessageViewModel : } } - var accountViewModel: AccountViewModel? = null - var account: Account? = null var room: ChatroomKey? by mutableStateOf(null) var requiresNIP17: Boolean = false @@ -127,7 +127,7 @@ class ChatNewMessageViewModel : var uploadState by mutableStateOf(null) val iMetaAttachments = IMetaAttachments() - var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) + var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) override var message by mutableStateOf(TextFieldValue("")) @@ -169,11 +169,11 @@ class ChatNewMessageViewModel : // NIP17 Wrapped DMs / Group messages var nip17 by mutableStateOf(false) - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + fun lnAddress(): String? = account.userProfile().info?.lnAddress() - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account?.userProfile() + fun user(): User? = account.userProfile() fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -189,7 +189,7 @@ class ChatNewMessageViewModel : this.uploadState = ChatFileUploadState( - account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0], + account.settings.defaultFileServer, ) } @@ -231,7 +231,6 @@ class ChatNewMessageViewModel : urlPreviews.update(message) // creates a split with that author. - val accountViewModel = accountViewModel ?: return val quotedAuthor = quote.author ?: return if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { @@ -255,14 +254,12 @@ class ChatNewMessageViewModel : if (noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } @@ -270,20 +267,19 @@ class ChatNewMessageViewModel : private fun loadFromDraft(draft: Note) { val draftEvent = draft.event ?: return - val accountViewModel = accountViewModel ?: return - val localfowardZapTo = draftEvent.tags.zapSplitSetup() - val totalWeight = localfowardZapTo.sumOf { it.weight } + val localForwardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localForwardZapTo.sumOf { it.weight } forwardZapTo.value = SplitBuilder() - localfowardZapTo.forEach { + localForwardZapTo.forEach { if (it is ZapSplitSetup) { val user = LocalCache.getOrCreateUser(it.pubKeyHex) forwardZapTo.value.addItem(user, (it.weight / totalWeight).toFloat()) } - // don't support edditing old-style splits. + // don't support editing old-style splits. } forwardZapToEditting.value = TextFieldValue("") - wantsForwardZapTo = localfowardZapTo.isNotEmpty() + wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -324,8 +320,8 @@ class ChatNewMessageViewModel : } } } else if (draftEvent is PrivateDmEvent) { - val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } - toUsers = TextFieldValue("@$recepientNpub") + val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } + toUsers = TextFieldValue("@$recipientNPub") val replyId = draftEvent.replyTo() if (replyId != null) { @@ -337,7 +333,7 @@ class ChatNewMessageViewModel : message = if (draftEvent is PrivateDmEvent) { - TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "") + TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "") } else { TextFieldValue(draftEvent.content) } @@ -349,22 +345,15 @@ class ChatNewMessageViewModel : nip17 = draftEvent is NIP17Group } - fun sendPost(onDone: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - sendPostSync() - onDone() - } - } - suspend fun sendPostSync() { innerSendPost(null) - accountViewModel?.deleteDraft(draftTag.current) + accountViewModel.deleteDraft(draftTag.current) cancel() } suspend fun sendDraftSync() { if (message.text.isBlank()) { - account?.deleteDraft(draftTag.current) + account.deleteDraft(draftTag.current) } else { innerSendPost(draftTag.current) } @@ -387,20 +376,21 @@ class ChatNewMessageViewModel : context: Context, onceUploaded: () -> Unit, ) { - val account = account ?: return val uploadState = uploadState ?: return - if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, viewModelScope, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, viewModelScope, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() + accountViewModel.runIOCatching { + if (nip17) { + ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() + } + } else { + ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() + } } } } @@ -411,27 +401,27 @@ class ChatNewMessageViewModel : onceUploaded: () -> Unit, ) { val room = room ?: return - val account = account ?: return val uploadState = uploadState ?: return - if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, viewModelScope, onError, context) { - ChatFileSender(room, account).sendNIP17(it) - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, viewModelScope, onError, context) { - ChatFileSender(room, account).sendNIP04(it) - draftTag.newVersion() - onceUploaded() + accountViewModel.runIOCatching { + if (nip17) { + ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded() + } + } else { + ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { + ChatFileSender(room, account).sendNIP04(it) + draftTag.newVersion() + onceUploaded() + } } } } - private fun innerSendPost(dTag: String?) { + private suspend fun innerSendPost(dTag: String?) { val room = room ?: return - val accountViewModel = accountViewModel ?: return val urls = findURLs(message.text) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -536,12 +526,6 @@ class ChatNewMessageViewModel : draftTag.rotate() } - fun deleteDraft() { - viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.deleteDraft(draftTag.current) - } - } - fun addToMessage(it: String) { updateMessage(TextFieldValue(message.text + " " + it)) } @@ -578,7 +562,7 @@ class ChatNewMessageViewModel : fun updateRoomFromUsersInput() { viewModelScope.launch(Dispatchers.Default) { delay(300) - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) + val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel) toUsersTagger.run() val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } @@ -622,7 +606,7 @@ class ChatNewMessageViewModel : toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() - val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays() + val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm() nip17 = relayList != null } @@ -648,10 +632,9 @@ class ChatNewMessageViewModel : val wordToInsert = item.link.url + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare( - item.link.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, - ) + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url)) + } } message = message.replaceCurrentWord(wordToInsert) @@ -701,7 +684,7 @@ class ChatNewMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index d2c440c7c1..0b079e56d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -50,14 +50,11 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -70,12 +67,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -92,12 +86,11 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -122,8 +115,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp @@ -134,6 +125,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable @@ -159,10 +151,8 @@ fun NewGroupDMScreen( val context = LocalContext.current - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } @@ -177,39 +167,31 @@ fun NewGroupDMScreen( Scaffold( topBar = { - TopAppBar( - actions = { - ActionButton(postViewModel, accountViewModel, nav) + PostingTopBar( + titleRes = R.string.private_message, + isActive = postViewModel::canPost, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendDraftSync() + delay(100) + nav.popBack() + postViewModel.cancel() + } }, - title = { - Text( - text = stringRes(R.string.private_message), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendPostSync() + postViewModel.room?.let { + nav.nav(routeToMessage(it, null, null, null, accountViewModel)) + } + postViewModel.cancel() + } + nav.popBack() }, - navigationIcon = { - CloseButton( - modifier = HalfStartPadding, - onPress = { - // uses the accountViewModel scope to avoid cancelling this - // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendDraftSync() - delay(100) - nav.popBack() - postViewModel.cancel() - } - }, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> @@ -225,30 +207,6 @@ fun NewGroupDMScreen( } } -@Composable -fun ActionButton( - postViewModel: ChatNewMessageViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - PostButton( - modifier = HalfEndPadding, - onPost = { - // uses the accountViewModel scope to avoid cancelling this - // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendPostSync() - postViewModel.room?.let { - nav.nav(routeToMessage(it, null, null, null, accountViewModel)) - } - postViewModel.cancel() - } - nav.popBack() - }, - isActive = postViewModel.canPost(), - ) -} - @Composable fun GroupDMScreenContent( postViewModel: ChatNewMessageViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 19868a1b33..cc557c2d06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -38,8 +38,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -145,7 +145,10 @@ fun EditField( isActive = channelScreenModel.canPost(), modifier = EditFieldTrailingIconModifier, ) { - channelScreenModel.sendPost(onSendNewMessage) + accountViewModel.runIOCatching { + channelScreenModel.sendPostSync() + onSendNewMessage() + } } }, leadingIcon = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt index d6a7770476..34912b7c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt @@ -20,9 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -34,11 +33,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff -import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch @@ -72,19 +71,17 @@ fun ToggleNip17Button( }, ) { if (channelScreenModel.nip17) { - IncognitoIconOn( - modifier = - Modifier - .padding(top = 2.dp) - .size(20.dp), + Icon( + painter = painterRes(R.drawable.incognito, 2), + contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), + modifier = IncognitoIconButtonModifier, tint = MaterialTheme.colorScheme.primary, ) } else { - IncognitoIconOff( - modifier = - Modifier - .padding(top = 2.dp) - .size(20.dp), + Icon( + painter = painterRes(R.drawable.incognito_off, 2), + contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message), + modifier = IncognitoIconButtonModifier, tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -103,6 +100,7 @@ fun NewFeatureNIP17AlertDialog( title = stringRes(R.string.new_feature_nip17_might_not_be_available_title), textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description), buttonIconResource = R.drawable.incognito, + buttonIconReference = 3, buttonText = stringRes(R.string.new_feature_nip17_activate), onClickDoOnce = { scope.launch { onConfirm() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 8d22aeeb62..344fc1bf55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -34,7 +34,7 @@ class ChatFileSender( val chatroom: ChatroomKey, val account: Account, ) { - fun sendNIP17(uploads: List) { + suspend fun sendNIP17(uploads: List) { uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) @@ -42,7 +42,7 @@ class ChatFileSender( } } - fun sendNIP17( + suspend fun sendNIP17( result: UploadOrchestrator.OrchestratorResult.ServerResult, caption: String?, contentWarningReason: String?, @@ -73,7 +73,7 @@ class ChatFileSender( // NIP 04 // ------ - fun sendNIP04(uploads: List) { + suspend fun sendNIP04(uploads: List) { uploads.forEach { if (it.cipher == null) { sendNIP04(it.result, it.caption, it.contentWarningReason) @@ -81,7 +81,7 @@ class ChatFileSender( } } - fun sendNIP04( + suspend fun sendNIP04( result: UploadOrchestrator.OrchestratorResult.ServerResult, caption: String?, contentWarningReason: String?, @@ -103,7 +103,7 @@ class ChatFileSender( ) } - fun sendAll(uploads: List) { + suspend fun sendAll(uploads: List) { uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 918cff6384..3a8c60fb5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index 7471ba8671..db5c43d959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -28,9 +28,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch class ChatFileUploader( val account: Account, @@ -39,100 +36,90 @@ class ChatFileUploader( // NIP 17 // ------ - fun justUploadNIP17( + suspend fun justUploadNIP17( viewState: ChatFileUploadState, - scope: CoroutineScope, onError: (title: String, message: String) -> Unit, context: Context, - onceUploaded: (List) -> Unit, + onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return + viewState.isUploadingImage = true - scope.launch(Dispatchers.Default) { - viewState.isUploadingImage = true + val cipher = AESGCM() - val cipher = AESGCM() + val results = + orchestrator.uploadEncrypted( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + ) - val results = - orchestrator.uploadEncrypted( - scope, - viewState.caption, - viewState.contentWarningReason, - MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - cipher, - viewState.selectedServer, - account, - context, - ) - - if (results.allGood) { - val list = - results.successful.mapNotNull { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) - } else { - null - } + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + } else { + null } + } - onceUploaded(list) - viewState.reset() - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - viewState.isUploadingImage = false + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } + + viewState.isUploadingImage = false } // ------ // NIP 04 // ------ - fun justUploadNIP04( + suspend fun justUploadNIP04( viewState: ChatFileUploadState, - scope: CoroutineScope, onError: (title: String, message: String) -> Unit, context: Context, - onceUploaded: (List) -> Unit, + onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return + viewState.isUploadingImage = true - scope.launch(Dispatchers.Default) { - viewState.isUploadingImage = true + val results = + orchestrator.upload( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + viewState.selectedServer, + account, + context, + ) - val results = - orchestrator.upload( - scope, - viewState.caption, - viewState.contentWarningReason, - MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - viewState.selectedServer, - account, - context, - ) - - if (results.allGood) { - val list = - results.successful.mapNotNull { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) - } else { - null - } + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) + } else { + null } + } - onceUploaded(list) - viewState.reset() - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - viewState.isUploadingImage = false + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } + + viewState.isUploadingImage = false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 56fdd2f5f5..b2119942e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -30,7 +30,7 @@ class ChannelFeedFilter( val channel: Channel, val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = channel.idHex + override fun feedKey() = channel // returns the last Note of each user. override fun feed(): List = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt index 79790d8ac6..bc579d4545 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ChannelQueryState( @@ -44,15 +44,11 @@ class ChannelFilterAssembler( ChannelFromUserFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt index 1d0374ea21..df4767a042 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt @@ -20,23 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelFromUserFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: ChannelQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = when (val channel = key.channel) { is EphemeralChatChannel -> filterMyMessagesToEphemeralChat(channel, userHex(key), since) is PublicChatChannel -> filterMyMessagesToPublicChat(channel, user(key).pubkeyHex, since) @@ -48,5 +49,5 @@ class ChannelFromUserFilterSubAssembler( override fun user(key: ChannelQueryState) = key.account.userProfile() - override fun list(key: ChannelQueryState) = key.channel.idHex + override fun list(key: ChannelQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt index 3c3a38263e..2330cefaeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -20,23 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelPublicFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ChannelQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = when (val channel = key.channel) { is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) is PublicChatChannel -> filterMessagesToPublicChat(channel, since) @@ -44,5 +45,5 @@ class ChannelPublicFilterSubAssembler( else -> null } - override fun id(key: ChannelQueryState) = key.channel.idHex + override fun id(key: ChannelQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt index 849650b0a6..f6cde11b5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt @@ -20,23 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMessagesToEphemeralChat( channel: EphemeralChatChannel, - since: Map?, -): List? = - listOf( - TypedFilter( - types = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { @@ -45,7 +43,7 @@ fun filterMessagesToEphemeralChat( mapOf("d" to listOfNotNull(channel.roomId.id)) }, limit = 200, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index c3177484c9..78aa37ce20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -20,26 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - tags = mapOf("a" to listOfNotNull(channel.idHex)), + tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt index 046b7a0264..256c3862a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt @@ -20,26 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMessagesToPublicChat( channel: PublicChatChannel, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + since: SincePerRelayMap?, +): List? = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to listOfNotNull(channel.idHex)), limit = 200, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt index 4e290248db..84988cf436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt @@ -20,25 +20,23 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMyMessagesToEphemeralChat( channel: EphemeralChatChannel, pubKey: HexKey, - since: Map?, -): List? = - listOf( - TypedFilter( - types = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { @@ -48,7 +46,7 @@ fun filterMyMessagesToEphemeralChat( }, authors = listOf(pubKey), limit = 50, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt index 40887b40ed..eef197437e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt @@ -20,29 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent fun filterMyMessagesToLiveActivities( channel: LiveActivitiesChannel, pubKey: HexKey, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + since: SincePerRelayMap?, +): List? = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - tags = mapOf("a" to listOfNotNull(channel.idHex)), + tags = mapOf("a" to listOfNotNull(channel.address.toValue())), authors = listOf(pubKey), limit = 50, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt index 25f7660c00..3fded09c0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt @@ -20,29 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMyMessagesToPublicChat( channel: PublicChatChannel, pubKey: HexKey, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to listOfNotNull(channel.idHex)), authors = listOf(pubKey), limit = 50, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt new file mode 100644 index 0000000000..c843e8cf16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import kotlinx.coroutines.launch + +@Composable +fun EphemeralChatChannelView( + channelId: RoomId?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + LoadEphemeralChatChannel(channelId, accountViewModel) { ephem -> + PrepareChannelViewModels( + baseChannel = ephem, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun PrepareChannelViewModels( + baseChannel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: ChannelFeedViewModel = + viewModel( + key = baseChannel.roomId.toKey() + "ChannelFeedViewModel", + factory = + ChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: ChannelNewMessageViewModel = viewModel() + channelScreenModel.init(accountViewModel) + channelScreenModel.load(baseChannel) + + ChannelView( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +private fun ChannelView( + channel: EphemeralChatChannel, + feedViewModel: ChannelFeedViewModel, + newPostModel: ChannelNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = + remember { + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + }, + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.roomId.toKey()}", + avoidDraft = newPostModel.draftTag, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch { + feedViewModel.sendToTop() + } + }, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt similarity index 59% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt index 4df883025c..a281a077f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt @@ -18,51 +18,18 @@ * 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.publicChannels +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId -@Composable -fun ChannelScreen( - channelId: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (channelId == null) return - - DisappearingScaffold( - isInvertedLayout = true, - topBar = { - LoadChannel(channelId, accountViewModel) { - when (it) { - is EphemeralChatChannel -> EphemeralChatTopBar(it, accountViewModel, nav) - is PublicChatChannel -> PublicChatTopBar(it, accountViewModel, nav) - is LiveActivitiesChannel -> LiveActivityTopBar(it, accountViewModel, nav) - } - } - }, - accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it)) { - ChannelView(channelId, accountViewModel, nav) - } - } -} - @Composable fun EphemeralChatScreen( channelId: RoomId, @@ -72,14 +39,14 @@ fun EphemeralChatScreen( DisappearingScaffold( isInvertedLayout = true, topBar = { - LoadChannel(channelId, accountViewModel) { + LoadEphemeralChatChannel(channelId, accountViewModel) { EphemeralChatTopBar(it, accountViewModel, nav) } }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { - ChannelView(channelId, accountViewModel, nav) + EphemeralChatChannelView(channelId, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt new file mode 100644 index 0000000000..b7c943c699 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId + +@Composable +fun LoadEphemeralChatChannel( + id: RoomId, + accountViewModel: AccountViewModel, + content: @Composable (EphemeralChatChannel) -> Unit, +) { + val channel = + produceStateIfNotNull(accountViewModel.getEphemeralChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreateEphemeralChatChannel(id) + } + + channel.value?.let { content(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt index fe3c58c4b7..7a529fd864 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt @@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt index d66213010a..bcc891f4e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 17c205572e..8a1a851880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header +import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,6 +30,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,47 +38,41 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.JoinChatButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.LeaveChatButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter @Composable fun loadRelayInfo( - relayUrl: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, -): State { - @Suppress("ProduceStateDoesNotAssignValue") - val relayInfo = - produceStateIfNotNull( - Nip11CachedRetriever.getFromCache(relayUrl), - relayUrl, - ) { - accountViewModel.retrieveRelayDocument( - relayUrl, - onInfo = { - value = it - }, - onError = { url, errorCode, exceptionMessage -> - }, - ) - } - - return relayInfo -} +): State = + produceState( + Nip11CachedRetriever.getFromCache(relay), + relay, + ) { + accountViewModel.retrieveRelayDocument( + relay = relay, + onInfo = { + value = it + }, + onError = { url, errorCode, exceptionMessage -> + Log.e("RelayInfo", "Error loading relay info for ${url.url}: $errorCode - $exceptionMessage") + }, + ) + } @Composable fun ShortEphemeralChatChannelHeader( @@ -124,14 +120,10 @@ private fun DrawRelayIcon( accountViewModel: AccountViewModel, ) { val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) - val info = - remember(channel.roomId.relayUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(channel.roomId.relayUrl)) - } RobohashFallbackAsyncImage( - robot = channel.idHex, - model = relayInfo?.icon ?: info.favIcon, + robot = channel.roomId.toKey(), + model = relayInfo?.icon, contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt index 5e3a2473eb..403881bd07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt @@ -24,8 +24,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt index 28eb28e6d5..ea4ff20c2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt @@ -24,8 +24,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt index 7cfd03779f..9a5525d291 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer class NewEphemeralChatMetaViewModel : ViewModel() { private var account: Account? = null @@ -42,7 +43,10 @@ class NewEphemeralChatMetaViewModel : ViewModel() { this.account = account } - fun buildRoom() = RoomId(channelName.value.text, relayUrl.value.text) + fun buildRoom() = + RelayUrlNormalizer + .normalizeOrNull(relayUrl.value.text) + ?.let { RoomId(channelName.value.text, it) } /* fun createOrUpdate(onDone: (RoomId) -> Unit) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt index e6ec5d2360..e25b6bcbfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -36,31 +34,24 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -104,48 +95,18 @@ private fun ChannelMetadataScaffold( ) { Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.relay_chat), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - JoinButton( - onPost = { - nav.popBack() - nav.nav(routeFor(postViewModel.buildRoom())) - }, - postViewModel.canPost, - ) + PostingTopBar( + titleRes = R.string.relay_chat, + isActive = postViewModel::canPost, + onCancel = { + nav.popBack() + }, + onPost = { + postViewModel.buildRoom()?.let { + nav.popBack() + nav.nav(routeFor(it)) } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - nav.popBack() - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt index 5183e84398..0d441a6d71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt @@ -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.chats.publicChannels +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -30,48 +30,28 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import kotlinx.coroutines.launch @Composable -fun ChannelView( +fun PublicChatChannelView( channelId: String?, accountViewModel: AccountViewModel, nav: INav, ) { if (channelId == null) return - LoadChannel(channelId, accountViewModel) { - PrepareChannelViewModels( - baseChannel = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun ChannelView( - channelId: RoomId?, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (channelId == null) return - - LoadChannel(channelId, accountViewModel) { + LoadPublicChatChannel(channelId, accountViewModel) { PrepareChannelViewModels( baseChannel = it, accountViewModel = accountViewModel, @@ -82,7 +62,7 @@ fun ChannelView( @Composable fun PrepareChannelViewModels( - baseChannel: Channel, + baseChannel: PublicChatChannel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -111,7 +91,7 @@ fun PrepareChannelViewModels( @Composable fun ChannelView( - channel: Channel, + channel: PublicChatChannel, feedViewModel: ChannelFeedViewModel, newPostModel: ChannelNewMessageViewModel, accountViewModel: AccountViewModel, @@ -130,9 +110,6 @@ fun ChannelView( .weight(1f, true) }, ) { - if (channel is LiveActivitiesChannel) { - ShowVideoStreaming(channel, accountViewModel) - } RefreshingChatroomFeedView( viewModel = feedViewModel, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelHeader.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelHeader.kt index 04844e8cc1..c5bfd8a49d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelHeader.kt @@ -18,85 +18,52 @@ * 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.publicChannels +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable -fun RenderChannelHeader( - channelNote: Note, - showVideo: Boolean, +fun RenderPublicChatChannelHeader( + channelId: HexKey, sendToChannel: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { - channelNote.channelHex()?.let { - ChannelHeader( - channelHex = it, - showVideo = showVideo, - sendToChannel = sendToChannel, - modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp), - accountViewModel = accountViewModel, - nav = nav, - ) - } + PublicChatChannelHeader( + channelHex = channelId, + sendToChannel = sendToChannel, + modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp), + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable -fun ChannelHeader( +fun PublicChatChannelHeader( channelHex: String, - showVideo: Boolean, - showFlag: Boolean = true, sendToChannel: Boolean = false, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, nav: INav, ) { - LoadChannel(channelHex, accountViewModel) { - when (it) { - is LiveActivitiesChannel -> - LiveActivitiesChannelHeader( - it, - showVideo, - showFlag, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - is PublicChatChannel -> - PublicChatChannelHeader( - it, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - - is EphemeralChatChannel -> - EphemeralChatChannelHeader( - it, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - } + LoadPublicChatChannel(channelHex, accountViewModel) { + PublicChatChannelHeader( + it, + sendToChannel, + modifier, + accountViewModel, + nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt new file mode 100644 index 0000000000..8c7cd6b93c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Composable +fun PublicChatChannelScreen( + channelId: HexKey?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + LoadPublicChatChannel(channelId, accountViewModel) { + PublicChatTopBar(it, accountViewModel, nav) + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + PublicChatChannelView(channelId, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 0ddf9f3d8e..625232a29b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -42,14 +42,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt index 17df36cc19..3279af82d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt @@ -28,9 +28,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt index bbca7b56e0..2ba8c3ca0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index 45bc9c57eb..e468db4a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -38,12 +38,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt index 8a7ba1bcc3..37e17b278b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ZeroPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt index 8dacd02330..230a8b4f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt @@ -24,8 +24,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt index 9f6b44c700..10a6233430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt @@ -24,8 +24,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt index 4229887983..abfd1b7ce2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt index 1108eb925f..1a95f0364b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt index ae533e4e00..57013ff8ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt @@ -32,8 +32,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index 3814ef2fd4..868a31b12b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -20,8 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import android.graphics.Picture import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -37,31 +36,27 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.note.LoadChannel -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -69,13 +64,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditF import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable fun ChannelMetadataScreen( @@ -86,10 +80,8 @@ fun ChannelMetadataScreen( if (channelId == null) { ChannelMetadataScreen(null as PublicChatChannel?, accountViewModel, nav) } else { - LoadChannel(channelId, accountViewModel) { - if (it is PublicChatChannel) { - ChannelMetadataScreen(it, accountViewModel, nav) - } + LoadPublicChatChannel(channelId, accountViewModel) { + ChannelMetadataScreen(it, accountViewModel, nav) } } } @@ -135,61 +127,49 @@ private fun ChannelMetadataScaffold( ) { Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.public_chat), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - if (postViewModel.isNewChannel()) { - CreateButton( - onPost = { - postViewModel.createOrUpdate { - nav.nav(routeFor(it)) - } - nav.popBack() - }, - postViewModel.canPost, - ) - } else { - SaveButton( - onPost = { - postViewModel.createOrUpdate { } - nav.popBack() - }, - postViewModel.canPost, + if (postViewModel.isNewChannel()) { + CreatingTopBar( + titleRes = R.string.public_chat, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate { + nav.nav(routeFor(it)) + } + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, ) } - } - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - nav.popBack() - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) + }, + ) + } else { + SavingTopBar( + titleRes = R.string.public_chat, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate { } + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } }, ) { pad -> val feedState by postViewModel.channelRelays.collectAsStateWithLifecycle() @@ -229,7 +209,7 @@ private fun ChannelMetadataScaffold( ) } - itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 91953ca272..dc2b2d24e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -84,7 +85,7 @@ class ChannelMetadataViewModel : ViewModel() { val relays = channel.info.relays ?.map { relaySetupInfoBuilder(it) } - ?.distinctBy { it.url } + ?.distinctBy { it.relay } _channelRelays.update { relays ?: emptyList() } } @@ -102,21 +103,18 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, ) - account.signAndSendPrivatelyOrBroadcast( - template, - relayList = { it.channelInfo().relays }, - onDone = { - val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) } - if (channel is PublicChatChannel) { - // follows the channel - account.follow(channel) - onDone(channel) - } - }, - ) + val signedResult = + account.signAndSendPrivatelyOrBroadcast( + template, + relayList = { it.channelInfo().relays }, + ) + val channel = LocalCache.getOrCreatePublicChatChannel(signedResult.id) + // follows the channel + account.follow(channel) + onDone(channel) } else { val event = channel.event @@ -128,7 +126,7 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, hint, ) } else { @@ -138,21 +136,18 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, eTag, ) } - account.signAndSendPrivatelyOrBroadcast( - template, - relayList = { it.channelInfo().relays }, - onDone = { - val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) } - if (channel is PublicChatChannel) { - onDone(channel) - } - }, - ) + val signedResult = + account.signAndSendPrivatelyOrBroadcast( + template, + relayList = { it.channelInfo().relays }, + ) + val channel = LocalCache.getOrCreatePublicChatChannel(signedResult.id) + onDone(channel) } } @@ -161,7 +156,7 @@ class ChannelMetadataViewModel : ViewModel() { } fun addHomeRelay(relay: BasicRelaySetupInfo) { - if (_channelRelays.value.any { it.url == relay.url }) return + if (_channelRelays.value.any { it.relay == relay.relay }) return _channelRelays.update { it.plus(relay) } } @@ -215,7 +210,7 @@ class ChannelMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = context, @@ -228,7 +223,7 @@ class ChannelMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -241,6 +236,9 @@ class ChannelMetadataViewModel : ViewModel() { onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) } catch (e: Exception) { if (e is CancellationException) throw e onUploading(false) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt new file mode 100644 index 0000000000..3cd0cd09ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import kotlinx.coroutines.launch + +@Composable +fun LiveActivityChannelView( + channelId: Address?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + LoadLiveActivityChannel(channelId, accountViewModel) { + PrepareChannelViewModels( + baseChannel = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun PrepareChannelViewModels( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: ChannelFeedViewModel = + viewModel( + key = baseChannel.address.toValue() + "ChannelFeedViewModel", + factory = + ChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: ChannelNewMessageViewModel = viewModel() + channelScreenModel.init(accountViewModel) + channelScreenModel.load(baseChannel) + + LiveActivityChannelView( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun LiveActivityChannelView( + channel: LiveActivitiesChannel, + feedViewModel: ChannelFeedViewModel, + newPostModel: ChannelNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = + remember { + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + }, + ) { + ShowVideoStreaming(channel, accountViewModel) + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.address.toValue()}", + avoidDraft = newPostModel.draftTag, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch { + feedViewModel.sendToTop() + } + }, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt index a02f1db605..7005fcde5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt @@ -28,9 +28,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -64,9 +64,9 @@ fun LiveActivitiesChannelHeader( ) { ShortLiveActivityChannelHeader( baseChannel = baseChannel, + showFlag = showFlag, accountViewModel = accountViewModel, nav = nav, - showFlag = showFlag, ) if (expanded.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt new file mode 100644 index 0000000000..4136ed19d0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address + +@Composable +fun LiveActivityChannelScreen( + channelId: Address?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + LoadLiveActivityChannel(channelId, accountViewModel) { + LiveActivityTopBar(it, accountViewModel, nav) + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + LiveActivityChannelView(channelId, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt deleted file mode 100644 index 865ee7284d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LikeReaction -import com.vitorpamplona.amethyst.ui.note.ZapReaction -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag - -@Composable -fun LiveChannelActionOptions( - channel: LiveActivitiesChannel, - showFlag: Boolean = true, - accountViewModel: AccountViewModel, - nav: INav, -) { - val isLive by remember(channel) { derivedStateOf { channel.info?.status() == StatusTag.STATUS.LIVE.code } } - - val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } - - note?.let { - if (showFlag && isLive) { - LiveFlag() - Spacer(modifier = StdHorzSpacer) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing, - ) { - LikeReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt index 5ef665f554..6f4d0918c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53 import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -39,15 +40,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel -import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -60,8 +61,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -79,44 +80,14 @@ fun LongLiveActivityChannelHeader( ) { val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? LiveActivitiesChannel ?: return + val activity = channel.info ?: return + val callbackUri = remember(channel) { channel.toNostrUri() } - Row( - lineModifier, - ) { - val summary = remember(channelState) { channel.summary()?.ifBlank { null } } - - Column( - Modifier.weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - val tags = remember(channelState) { baseChannel.info?.tags?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = summary ?: stringRes(id = R.string.groups_no_descriptor), - canPreview = false, - quotesLeft = 1, - tags = tags, - backgroundColor = background, - id = baseChannel.idHex, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (summary != null) { - baseChannel.info?.let { - if (it.hasHashtags()) { - DisplayUncitedHashtags(it, summary, nav) - } - } - } - } + Row(lineModifier) { + RenderSummary(activity, callbackUri, accountViewModel, nav) } - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> + LoadAddressableNote(channel.address, accountViewModel) { loadingNote -> loadingNote?.let { note -> Row( lineModifier, @@ -197,3 +168,36 @@ fun LongLiveActivityChannelHeader( } } } + +@Composable +private fun RowScope.RenderSummary( + activity: LiveActivitiesEvent, + callbackUri: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val summary = activity.summary() ?: stringRes(id = R.string.groups_no_descriptor) + + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary, + canPreview = false, + quotesLeft = 1, + tags = activity.tags.toImmutableListOfLists(), + backgroundColor = background, + id = activity.id, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (activity.hasHashtags()) { + DisplayUncitedHashtags(activity, summary, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt index 304e75ac32..0517c843cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt @@ -23,36 +23,65 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @Composable fun ShortLiveActivityChannelHeader( baseChannel: LiveActivitiesChannel, + showFlag: Boolean, accountViewModel: AccountViewModel, nav: INav, - showFlag: Boolean, ) { val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? LiveActivitiesChannel ?: return + ShortLiveActivityChannelHeader( + name = channel.toBestDisplayName(), + creator = channel.creator, + liveActivitiesEvent = channel.info, + showFlag = showFlag, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun ShortLiveActivityChannelHeader( + name: String, + creator: User?, + liveActivitiesEvent: LiveActivitiesEvent?, + showFlag: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { Row(verticalAlignment = Alignment.CenterVertically) { - channel.creator?.let { + creator?.let { UserPicture( user = it, size = Size34dp, @@ -71,21 +100,60 @@ fun ShortLiveActivityChannelHeader( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = remember(channelState) { channel.toBestDisplayName() }, + text = name, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } - Row( - modifier = - Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) + liveActivitiesEvent?.let { + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LiveChannelActionOptions(it, showFlag, accountViewModel, nav) + } } } } + +@Composable +fun LiveChannelActionOptions( + activity: LiveActivitiesEvent, + showFlag: Boolean = true, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isLive by remember(activity) { derivedStateOf { activity.isLive() } } + + if (showFlag && isLive) { + LiveFlag() + Spacer(modifier = StdHorzSpacer) + } + + val note = remember(activity) { LocalCache.getAddressableNoteIfExists(activity.address()) } + note?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = RowColSpacing, + ) { + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + } + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt index a948aac873..98167d056e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt @@ -28,7 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelInfo import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt index 671d0d89fb..81d36e517e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.liveStreamTag +import com.vitorpamplona.quartz.utils.TimeUtils import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -92,11 +93,15 @@ fun OfflineFlag() { fun ScheduledFlag(starts: Long?) { val startsIn = starts?.let { - SimpleDateFormat - .getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT, - ).format(Date(starts * 1000)) + if (it > TimeUtils.now()) { + SimpleDateFormat + .getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + ).format(Date(starts * 1000)) + } else { + null + } } Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt index 913e504035..fa6e0516e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LongLiveActivityChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShortLiveActivityChannelHeader @@ -38,9 +38,9 @@ fun LiveActivityTopBar( title = { ShortLiveActivityChannelHeader( baseChannel = baseChannel, + showFlag = true, accountViewModel = accountViewModel, nav = nav, - showFlag = true, ) }, extendableRow = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 7d41e7a285..ac5d4d9ea6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -35,11 +35,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a2b3b03a3c..0132607254 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -37,13 +37,13 @@ import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel 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.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -67,6 +67,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -196,14 +197,12 @@ open class ChannelNewMessageViewModel : if (noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel?.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } @@ -273,7 +272,7 @@ open class ChannelNewMessageViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val channelRelays = channel?.relays() ?: emptyList() + val channelRelays = channel?.relays() ?: emptySet() accountViewModel?.account?.signAndSendPrivately(template, channelRelays) @@ -301,6 +300,19 @@ open class ChannelNewMessageViewModel : onError: (title: String, message: String) -> Unit, context: Context, onceUploaded: () -> Unit, + ) = try { + uploadUnsafe(onError, context, onceUploaded) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, ) { viewModelScope.launch(Dispatchers.Default) { val myAccount = account ?: return@launch @@ -312,7 +324,6 @@ open class ChannelNewMessageViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, uploadState.caption, uploadState.contentWarningReason, MediaCompressor.intToCompressorQuality(uploadState.mediaQualitySlider), @@ -324,16 +335,15 @@ open class ChannelNewMessageViewModel : if (results.allGood) { results.successful.forEach { if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, uploadState.caption, uploadState.contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val nip95 = myAccount.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, uploadState.caption, uploadState.contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - note?.let { - message = message.insertUrlAtCursor(it.toNostrUri()) - } - - urlPreview = findUrlInMessage() + note?.let { + message = message.insertUrlAtCursor(it.toNostrUri()) } + + urlPreview = findUrlInMessage() } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { iMetaAttachments.add(it.result, uploadState.caption, uploadState.contentWarningReason) @@ -364,7 +374,6 @@ open class ChannelNewMessageViewModel : message = message.text, pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), - channelHex = channel.idHex, dao = accountViewModel, ) tagger.run() @@ -579,7 +588,7 @@ open class ChannelNewMessageViewModel : viewModelScope.launch(Dispatchers.IO) { iMetaAttachments.downloadAndPrepare( item.link.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) ?: false) }, ) } @@ -627,7 +636,7 @@ open class ChannelNewMessageViewModel : fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 87497d68d2..c2f20ad7ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index a925625596..a2913fd3c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -48,9 +48,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.Route.NewGroupDM +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewGroupDM import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Size55Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index a50d7de059..8f2e7fe071 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -32,7 +32,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.mutableStateOf import androidx.compose.runtime.remember @@ -53,43 +52,41 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.logTime -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BlankNote -import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent -import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable 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.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter @Composable fun ChatroomHeaderCompose( @@ -97,18 +94,14 @@ fun ChatroomHeaderCompose( accountViewModel: AccountViewModel, nav: INav, ) { - logTime( - debugMessage = { "ChatroomHeaderCompose " + externalLinkForNote(baseNote) }, - ) { - if (baseNote.event != null) { + if (baseNote.event != null) { + ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) + } else { + val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) + if (hasEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { - val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) - if (hasEvent) { - ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) - } else { - BlankNote() - } + BlankNote() } } } @@ -119,80 +112,71 @@ fun ChatroomComposeChannelOrUser( accountViewModel: AccountViewModel, nav: INav, ) { - if (baseNote.event is DraftEvent) { - ObserveDraftEvent(baseNote, accountViewModel) { - val channelHex by remember(it) { derivedStateOf { it.channelHex() } } - - if (channelHex != null) { - ChatroomChannel(channelHex!!, it, accountViewModel, nav) - } else { - ChatroomPrivateMessages(it, accountViewModel, nav) - } + val baseNoteEvent = baseNote.event + if (baseNoteEvent is DraftEvent) { + ObserveDraftEvent(baseNote, accountViewModel) { innerNote -> + ChatroomEntry(innerNote, accountViewModel, nav) } } else { - val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } - - if (channelHex != null) { - ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) - } else { - ChatroomPrivateMessages(baseNote, accountViewModel, nav) - } + ChatroomEntry(baseNote, accountViewModel, nav) } } @Composable -private fun ChatroomPrivateMessages( - baseNote: Note, +private fun ChatroomEntry( + lastMessage: Note, accountViewModel: AccountViewModel, nav: INav, ) { - val userRoom by - remember(baseNote) { - derivedStateOf { - (baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) + val baseNoteEvent = lastMessage.event + when (baseNoteEvent) { + is ChannelMessageEvent -> + baseNoteEvent.channelId()?.let { + LoadPublicChatChannel(it, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } + } + is ChannelMetadataEvent -> + baseNoteEvent.channelId()?.let { + LoadPublicChatChannel(it, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } + } + is ChannelCreateEvent -> + LoadPublicChatChannel(baseNoteEvent.id, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } + is ChatroomKeyable -> { + val room = baseNoteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex) + UserRoomCompose(room, lastMessage, accountViewModel, nav) + } + is EphemeralChatEvent -> { + baseNoteEvent.roomId()?.let { + LoadEphemeralChatChannel(it, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } } } - - CrossfadeIfEnabled(targetState = userRoom, label = "ChatroomPrivateMessages", accountViewModel = accountViewModel) { room -> - if (room != null) { - UserRoomCompose(baseNote, room, accountViewModel, nav) - } else { - BlankNote() - } - } -} - -@Composable -private fun ChatroomChannel( - channelHex: HexKey, - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel -> - when (channel) { - is PublicChatChannel -> ChannelRoomCompose(baseNote, channel, accountViewModel, nav) - is EphemeralChatChannel -> ChannelRoomCompose(baseNote, channel, accountViewModel, nav) - } + else -> BlankNote() } } @Composable private fun ChannelRoomCompose( - note: Note, + lastMessage: Note, channel: PublicChatChannel, accountViewModel: AccountViewModel, nav: INav, ) { - val authorName by observeUserName(note.author!!, accountViewModel) + val authorName by observeUserName(lastMessage.author!!, accountViewModel) val channelState by observeChannel(channel, accountViewModel) - val channelPicture = channelState?.channel?.profilePicture() ?: channel.profilePicture() - val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName() + val channel = channelState?.channel as? PublicChatChannel ?: return - val noteEvent = note.event + val channelPicture = channel.profilePicture() + val channelName = channel.toBestDisplayName() - val route = Route.Channel(channel.idHex) + val noteEvent = lastMessage.event val description = if (noteEvent is ChannelCreateEvent) { @@ -209,58 +193,44 @@ private fun ChannelRoomCompose( channelIdHex = channel.idHex, channelPicture = channelPicture, channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.public_chat, modifier) }, - channelLastTime = note.createdAt(), + channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onClick = { nav.nav(route) }, + onClick = { nav.nav(routeFor(channel)) }, ) } @Composable private fun ChannelRoomCompose( - note: Note, + lastMessage: Note, channel: EphemeralChatChannel, accountViewModel: AccountViewModel, nav: INav, ) { - val authorName by observeUserName(note.author!!, accountViewModel) + val authorName by observeUserName(lastMessage.author!!, accountViewModel) val channelState by observeChannel(channel, accountViewModel) + val channel = channelState?.channel as? EphemeralChatChannel ?: return + val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) - val info = - remember(channel.roomId.relayUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(channel.roomId.relayUrl)) - } - val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName() + val noteEvent = lastMessage.event + val description = noteEvent?.content?.take(200) - val noteEvent = note.event - - val route = Route.Channel(channel.idHex) - - val description = - if (noteEvent is ChannelCreateEvent) { - stringRes(R.string.channel_created) - } else if (noteEvent is ChannelMetadataEvent) { - "${stringRes(R.string.channel_information_changed_to)} " - } else { - noteEvent?.content?.take(200) - } - - val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.idHex}").collectAsStateWithLifecycle() + val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.roomId.toKey()}").collectAsStateWithLifecycle() ChannelName( - channelIdHex = channel.idHex, - channelPicture = relayInfo?.icon ?: info.favIcon, - channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.ephemeral_relay_chat, modifier) }, - channelLastTime = note.createdAt(), + channelIdHex = channel.roomId.toKey(), + channelPicture = relayInfo.icon, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(channel.toBestDisplayName(), R.string.ephemeral_relay_chat, modifier) }, + channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onClick = { nav.nav(route) }, + onClick = { nav.nav(routeFor(channel)) }, ) } @@ -306,8 +276,8 @@ private fun ChannelTitleWithLabelInfo( @Composable private fun UserRoomCompose( - note: Note, room: ChatroomKey, + lastMessage: Note, accountViewModel: AccountViewModel, nav: INav, ) { @@ -321,10 +291,10 @@ private fun UserRoomCompose( }, firstRow = { RoomNameDisplay(room, Modifier.weight(1f), accountViewModel) - TimeAgo(note.createdAt()) + TimeAgo(lastMessage.createdAt()) }, secondRow = { - LoadDecryptedContentOrNull(note, accountViewModel) { content -> + LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content -> if (content != null) { Text( content, @@ -346,11 +316,11 @@ private fun UserRoomCompose( } val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() - if ((note.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { NewItemsBubble() } }, - onClick = { nav.nav(Route.Room(room.hashCode())) }, + onClick = { nav.nav(Route.Room(room)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index d9717c0ade..000ab65f9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index b691d47010..a3773be358 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -39,28 +39,27 @@ class ChatroomListKnownFeedFilter( // returns the last Note of each user. override fun feed(): List { - val me = account.userProfile() + val chatList = account.chatroomList val followingKeySet = account.followingKeySet() - val knownChatrooms = - me.privateChatrooms.filter { - (it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && - !account.isAllHidden(it.key.users) - } - val privateMessages = - knownChatrooms.mapNotNull { it -> - it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { - it.event != null + chatList.rooms.mapNotNull { key, chatroom -> + if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) && + !account.isAllHidden(key.users) + ) { + chatroom.lastMessage + } else { + null } } val publicChannels = account - .publicChatList.livePublicChatList.value - .mapNotNull { LocalCache.getChannelIfExists(it.eventId) } + .publicChatList.flow.value .mapNotNull { it -> - it.notes + LocalCache + .getOrCreatePublicChatChannel(it.eventId) + .notes .filter { key, it -> account.isAcceptable(it) && it.event != null } .sortedWith(DefaultFeedOrder) .firstOrNull() @@ -69,9 +68,10 @@ class ChatroomListKnownFeedFilter( val ephemeralChats = account .ephemeralChatList.liveEphemeralChatList.value - .mapNotNull { LocalCache.getChannelIfExists(it) } .mapNotNull { it -> - it.notes + LocalCache + .getOrCreateEphemeralChannel(it) + .notes .filter { key, it -> account.isAcceptable(it) && it.event != null } .sortedWith(DefaultFeedOrder) .firstOrNull() @@ -102,7 +102,8 @@ class ChatroomListKnownFeedFilter( newRelevantPublicMessages.forEach { newNotePair -> var hasUpdated = false oldList.forEach { oldNote -> - if (newNotePair.key == oldNote.channelHex()) { + val channelId = (oldNote.event as? ChannelMessageEvent)?.channelId() + if (newNotePair.key == channelId) { hasUpdated = true if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { myNewList = myNewList.replace(oldNote, newNotePair.value) @@ -117,7 +118,8 @@ class ChatroomListKnownFeedFilter( newRelevantEphemeralChats.forEach { newNotePair -> var hasUpdated = false oldList.forEach { oldNote -> - if (newNotePair.key.toKey() == oldNote.channelHex()) { + val noteEvent = (oldNote.event as? EphemeralChatEvent)?.roomId() + if (newNotePair.key == noteEvent) { hasUpdated = true if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { myNewList = myNewList.replace(oldNote, newNotePair.value) @@ -168,20 +170,20 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { - val followingChannels = account.publicChatList.livePublicChatEventIdSet.value + val followingChannels = account.publicChatList.flowSet.value val newRelevantPublicMessages = mutableMapOf() newItems - .filter { it.event is ChannelMessageEvent } .forEach { newNote -> - newNote.channelHex()?.let { channelHex -> - if (channelHex in followingChannels && account.isAcceptable(newNote)) { - val lastNote = newRelevantPublicMessages.get(channelHex) + val channelId = (newNote.event as? ChannelMessageEvent)?.channelId() + if (channelId != null) { + if (channelId in followingChannels && account.isAcceptable(newNote)) { + val lastNote = newRelevantPublicMessages.get(channelId) if (lastNote != null) { if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPublicMessages.put(channelHex, newNote) + newRelevantPublicMessages.put(channelId, newNote) } } else { - newRelevantPublicMessages.put(channelHex, newNote) + newRelevantPublicMessages.put(channelId, newNote) } } } @@ -198,10 +200,9 @@ class ChatroomListKnownFeedFilter( newItems .forEach { newNote -> val noteEvent = newNote.event as? EphemeralChatEvent - if (noteEvent != null) { val room = noteEvent.roomId() - if (room in followingEphemeralChats && account.isAcceptable(newNote)) { + if (room != null && room in followingEphemeralChats && account.isAcceptable(newNote)) { val lastNote = newRelevantEphemeralChats.get(room) if (lastNote != null) { if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { @@ -225,27 +226,27 @@ class ChatroomListKnownFeedFilter( val newRelevantPrivateMessages = mutableMapOf() newItems - .filter { it.event is ChatroomKeyable } .forEach { newNote -> val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] - - if (roomKey != null && room != null) { - if ( - ( - newNote.author?.pubkeyHex == me.pubkeyHex || - room.senderIntersects(followingKeySet) || - me.hasSentMessagesTo(roomKey) - ) && - !account.isAllHidden(roomKey.users) - ) { - val lastNote = newRelevantPrivateMessages.get(roomKey) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + if (roomKey != null) { + val room = account.chatroomList.rooms.get(roomKey) + if (room != null) { + if ( + ( + newNote.author?.pubkeyHex == me.pubkeyHex || + room.senderIntersects(followingKeySet) || + account.chatroomList.hasSentMessagesTo(roomKey) + ) && + !account.isAllHidden(roomKey.users) + ) { + val lastNote = newRelevantPrivateMessages.get(roomKey) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } else { newRelevantPrivateMessages.put(roomKey, newNote) } - } else { - newRelevantPrivateMessages.put(roomKey, newNote) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 6c9d1fea03..969695f432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -36,20 +35,15 @@ class ChatroomListNewFeedFilter( // returns the last Note of each user. override fun feed(): List { - val me = account.userProfile() + val chatList = account.chatroomList val followingKeySet = account.followingKeySet() - val newChatrooms = - me.privateChatrooms.filter { - !it.value.senderIntersects(followingKeySet) && - !me.hasSentMessagesTo(it.key) && - !account.isAllHidden(it.key.users) - } - val privateMessages = - newChatrooms.mapNotNull { it -> - it.value.roomMessages.sortedWith(DefaultFeedOrder).firstOrNull { - it.event != null + chatList.rooms.mapNotNull { key, chatroom -> + if (!chatroom.senderIntersects(followingKeySet) && !chatList.hasSentMessagesTo(key) && !account.isAllHidden(key.users)) { + chatroom.lastMessage + } else { + null } } @@ -110,19 +104,17 @@ class ChatroomListNewFeedFilter( val followingKeySet = account.followingKeySet() val newRelevantPrivateMessages = mutableMapOf() - newItems - .filter { it.event is PrivateDmEvent } - .forEach { newNote -> - val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] + newItems.forEach { newNote -> + val noteEvent = newNote.event + if (noteEvent is ChatroomKeyable) { + val roomKey = noteEvent.chatroomKey(me.pubkeyHex) + val room = account.chatroomList.rooms.get(roomKey) - if ( - roomKey != null && - room != null && + if (room != null && ( newNote.author?.pubkeyHex != me.pubkeyHex && room.senderIntersects(followingKeySet) && - !me.hasSentMessagesTo(roomKey) + !account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) ) { @@ -136,6 +128,8 @@ class ChatroomListNewFeedFilter( } } } + } + return newRelevantPrivateMessages } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 13dbf03f51..fa72099c3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ChatroomListState( @@ -39,15 +39,11 @@ class ChatroomListFilterAssembler( FollowingEphemeralChatSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 134c88999b..8463cab688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -20,10 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class DMsFromUserFilterSubAssembler( client: NostrClient, @@ -31,11 +38,45 @@ class DMsFromUserFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomListState, - since: Map?, - ): List? = - listOfNotNull( - filterNip04DMsToAndFromMe(key.account.userProfile().pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List? = + key.account.outboxRelays.flow.value.map { + filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) + } + + key.account.dmRelays.flow.value.map { + filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) + } override fun user(key: ChatroomListState) = key.account.userProfile() + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.outboxRelays.flow.collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.account.dmRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 111b96f05d..1c1c68f193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -20,39 +20,44 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.mapOfSet fun filterFollowingEphemeralChats( - followingChannels: Set?, - since: Map?, -): List? { - if (followingChannels == null || followingChannels.isEmpty()) return null + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null - val dTags = - followingChannels.map { - if (it.id.isBlank()) { - "_" - } else { - it.id + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { room -> + if (!room.relayUrl.url.isBlank()) { + add(room.relayUrl, room.id.ifBlank { "_" }) + } else { + Constants.eventFinderRelays.forEach { + add(it, room.id.ifBlank { "_" }) + } + } } } - return listOf( - TypedFilter( + return relayRoomDTags.map { + RelayBasedFilter( // Metadata comes from any relay - types = setOf(FeedType.PUBLIC_CHATS), + relay = it.key, filter = - SincePerRelayFilter( + Filter( kinds = listOf(EphemeralChatEvent.KIND), - tags = mapOf("d" to dTags), + tags = mapOf("d" to it.value.sorted()), limit = 100, - since = since, + since = since?.get(it.key)?.time, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index acc4857427..e836d3d95b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -20,29 +20,44 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import kotlin.collections.toList +import com.vitorpamplona.quartz.utils.mapOfSet fun filterFollowingPublicChats( - followingChannels: Set?, - since: Map?, -): List? { - if (followingChannels == null || followingChannels.isEmpty()) return null + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { channelId -> + val relays = + LocalCache.getPublicChatChannelIfExists(channelId)?.relays() + ?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null } + ?: Constants.eventFinderRelays + + relays.forEach { relayUrl -> + add(relayUrl, channelId) + } + } + } + + return relayRoomDTags.map { + RelayBasedFilter( + relay = it.key, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ChannelCreateEvent.KIND), - ids = followingChannels.toList(), - since = since, + ids = it.value.sorted(), + since = since?.get(it.key)?.time, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 7fe0b34d4f..0c9c831418 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -20,44 +20,61 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import kotlin.collections.toList +import com.vitorpamplona.quartz.utils.mapOfSet fun filterLastMessageFollowingPublicChats( - followingChannels: Set?, - since: Map?, -): List? { - if (followingChannels == null || followingChannels.isEmpty()) return null + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null - return listOf( - TypedFilter( - // Metadata comes from any relay - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to followingChannels.toList()), - since = since, - limit = 1, + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { channelId -> + val relays = + LocalCache.getPublicChatChannelIfExists(channelId)?.relays() + ?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null } + ?: Constants.eventFinderRelays + + relays.forEach { relayUrl -> + add(relayUrl, channelId) + } + } + } + + return relayRoomDTags + .map { + listOf( + RelayBasedFilter( + // Metadata comes from any relay + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to it.value.sorted()), + since = since?.get(it.key)?.time, + limit = 1, + ), ), - ), - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to followingChannels.toList()), - since = since, - // Remember to consider spam that is being removed from the UI - limit = 100, + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to it.value.sorted()), + since = since?.get(it.key)?.time, + // Remember to consider spam that is being removed from the UI + limit = 100, + ), ), - ), - ) + ) + }.flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt new file mode 100644 index 0000000000..e32888b580 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +fun filterNip04DMsFromMe( + user: User, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(user.pubkeyHex), + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt new file mode 100644 index 0000000000..2488bed77f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +fun filterNip04DMsToMe( + user: User, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(user.pubkeyHex)), + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt index b1dfcbd56b..3c46a38764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt @@ -22,17 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -import kotlin.collections.forEach class FollowingEphemeralChatSubAssembler( client: NostrClient, @@ -40,8 +39,8 @@ class FollowingEphemeralChatSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomListState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = listOfNotNull( filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since), ).flatten() @@ -56,7 +55,7 @@ class FollowingEphemeralChatSubAssembler( userJobMap[key.account.userProfile()] = listOf( key.account.scope.launch(Dispatchers.Default) { - key.account.ephemeralChatList.liveEphemeralChatList.sample(5000).collectLatest { + key.account.ephemeralChatList.liveEphemeralChatList.sample(500).collectLatest { invalidateFilters() } }, @@ -69,7 +68,7 @@ class FollowingEphemeralChatSubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt index dc5e035257..a59c0fdc5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt @@ -22,17 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -import kotlin.collections.forEach class FollowingPublicChatSubAssembler( client: NostrClient, @@ -40,11 +39,11 @@ class FollowingPublicChatSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomListState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = listOfNotNull( - filterLastMessageFollowingPublicChats(key.account.publicChatList.livePublicChatEventIdSet.value, since), - filterFollowingPublicChats(key.account.publicChatList.livePublicChatEventIdSet.value, since), + filterLastMessageFollowingPublicChats(key.account.publicChatList.flowSet.value, since), + filterFollowingPublicChats(key.account.publicChatList.flowSet.value, since), ).flatten() override fun user(key: ChatroomListState) = key.account.userProfile() @@ -57,7 +56,7 @@ class FollowingPublicChatSubAssembler( userJobMap[key.account.userProfile()] = listOf( key.account.scope.launch(Dispatchers.Default) { - key.account.publicChatList.livePublicChatEventIdSet.sample(5000).collectLatest { + key.account.publicChatList.flowSet.sample(500).collectLatest { invalidateFilters() } }, @@ -70,7 +69,7 @@ class FollowingPublicChatSubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 93b19a14c7..a43e09e9c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -28,8 +28,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -41,7 +39,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -53,11 +51,10 @@ fun ChatroomListFeedView( scrollStateKey: String, accountViewModel: AccountViewModel, nav: INav, - markAsRead: MutableState, ) { RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> - CrossFadeState(feedContentState, listState, accountViewModel, nav, markAsRead) + CrossFadeState(feedContentState, listState, accountViewModel, nav) } } } @@ -68,7 +65,6 @@ private fun CrossFadeState( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, - markAsRead: MutableState, ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() @@ -85,7 +81,7 @@ private fun CrossFadeState( FeedError(state.errorMessage) { feedContentState.invalidateData() } } is FeedState.Loaded -> { - FeedLoaded(state, listState, accountViewModel, nav, markAsRead) + FeedLoaded(state, listState, accountViewModel, nav) } FeedState.Loading -> { LoadingFeed() @@ -100,16 +96,9 @@ private fun FeedLoaded( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, - markAsRead: MutableState, ) { val items by loaded.feed.collectAsStateWithLifecycle() - LaunchedEffect(key1 = markAsRead.value) { - if (markAsRead.value) { - accountViewModel.markAllAsRead(items.list, accountViewModel) { markAsRead.value = false } - } - } - LazyColumn( contentPadding = FeedPadding, state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 8eb631bd2f..15a8a63713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -38,7 +38,6 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,7 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -62,7 +61,6 @@ class MessagesTabItem( val resource: Int, val scrollStateKey: String, val feedContentState: FeedContentState, - val markAsRead: MutableState, ) @Composable @@ -132,7 +130,6 @@ fun MessagesPager( scrollStateKey = tabs[page].scrollStateKey, accountViewModel = accountViewModel, nav = nav, - markAsRead = tabs[page].markAsRead, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 4b9038fa88..6503d9f858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -25,17 +25,17 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.MainTopBar -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription @@ -52,20 +52,17 @@ fun MessagesSinglePane( ) { val pagerState = rememberPagerState { 2 } - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } - WatchLifecycleAndUpdateModel(knownFeedContentState) WatchLifecycleAndUpdateModel(newFeedContentState) ChatroomListFilterAssemblerSubscription(accountViewModel) val tabs by - remember(knownFeedContentState, markKnownAsRead) { + remember(knownFeedContentState) { derivedStateOf { listOf( - MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState, markKnownAsRead), - MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState, markNewAsRead), + MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState), + MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState), ) } } @@ -74,12 +71,12 @@ fun MessagesSinglePane( isInvertedLayout = false, topBar = { Column { - MainTopBar(accountViewModel, nav) + UserDrawerSearchTopBar(accountViewModel, nav) { AmethystClickableIcon() } MessagesTabHeader( pagerState, tabs, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, + { accountViewModel.markAllChatNotesAsRead(knownFeedContentState.visibleNotes()) }, + { accountViewModel.markAllChatNotesAsRead(newFeedContentState.visibleNotes()) }, ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt index 0861aeee49..0525c2bf99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt @@ -26,14 +26,13 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager @@ -49,20 +48,17 @@ fun ChatroomList( ) { val pagerState = rememberPagerState { 2 } - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } - WatchLifecycleAndUpdateModel(knownFeedContentState) WatchLifecycleAndUpdateModel(newFeedContentState) ChatroomListFilterAssemblerSubscription(accountViewModel) val tabs by - remember(knownFeedContentState, markKnownAsRead) { + remember(knownFeedContentState) { derivedStateOf { listOf( - MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState, markKnownAsRead), - MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState, markNewAsRead), + MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState), + MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState), ) } } @@ -71,8 +67,8 @@ fun ChatroomList( MessagesTabHeader( pagerState, tabs, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, + { accountViewModel.markAllChatNotesAsRead(knownFeedContentState.visibleNotes()) }, + { accountViewModel.markAllChatNotesAsRead(newFeedContentState.visibleNotes()) }, ) MessagesPager( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 4aa5ffeb7d..c3048c143d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -37,13 +36,14 @@ import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.MainTopBar -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -62,22 +62,16 @@ fun MessagesTwoPane( val strategy = remember { if (widthSizeClass == WindowWidthSizeClass.Expanded) { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 3f, - ) + HorizontalTwoPaneStrategy(splitFraction = 1f / 3f) } else { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 2.5f, - ) + HorizontalTwoPaneStrategy(splitFraction = 1f / 2.5f) } } DisappearingScaffold( isInvertedLayout = false, topBar = { - Column { - MainTopBar(accountViewModel, nav) - } + UserDrawerSearchTopBar(accountViewModel, nav) { AmethystClickableIcon() } }, bottomBar = { AppBottomBar(Route.Message, accountViewModel) { route -> @@ -110,15 +104,18 @@ fun MessagesTwoPane( Box(Modifier.fillMaxSize().systemBarsPadding()) { twoPaneNav.innerNav.value?.let { if (it is Route.Room) { - Chatroom( - roomId = it.id.toString(), + ChatroomView( + room = it.toKey(), accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, nav = nav, ) } - if (it is Route.Channel) { - ChannelView( + if (it is Route.PublicChatChannel) { + PublicChatChannelView( channelId = it.id, accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt index e7dd5067a2..214a74fc59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt @@ -22,34 +22,33 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.material3.DrawerState import androidx.compose.runtime.mutableStateOf -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.reflect.KClass class TwoPaneNav( val nav: INav, - val scope: CoroutineScope, + override val scope: CoroutineScope, ) : INav { override val drawerState: DrawerState = nav.drawerState val innerNav = mutableStateOf(null) override fun nav(route: Route) { - if (route is Route.Room || route is Route.Channel) { + if (route is Route.Room || route is Route.PublicChatChannel) { innerNav.value = route } else { nav.nav(route) } } - override fun nav(routeMaker: suspend () -> Route?) { - scope.launch(Dispatchers.Default) { - val route = routeMaker() + override fun nav(computeRoute: suspend () -> Route?) { + scope.launch { + val route = computeRoute() if (route != null) { - if (route is Route.Room || route is Route.Channel) { + if (route is Route.Room || route is Route.PublicChatChannel) { innerNav.value = route } else { nav.nav(route) @@ -68,9 +67,9 @@ class TwoPaneNav( override fun popUpTo( route: Route, - upToClass: KClass, + klass: KClass, ) { - nav.popUpTo(route, upToClass) + nav.popUpTo(route, klass) } override fun closeDrawer() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 3a84eea9cf..668878e1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -41,7 +41,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -59,9 +58,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TitleIconModifier -import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -97,9 +96,7 @@ fun ChatFileUploadDialog( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), - modifier = Modifier, + ShorterTopAppBar( title = title, navigationIcon = { IconButton( @@ -148,7 +145,8 @@ private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, ) { - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList + .collectAsState() val fileServerOptions = remember(fileServers) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt index 99ea67eb66..cecbca8e02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt @@ -37,7 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.theme.Size20Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt index 35d3691a7d..f36fbf65cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt @@ -32,8 +32,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader @@ -42,16 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @Composable fun CommunityScreen( - aTagHex: String?, + aTagHex: Address, accountViewModel: AccountViewModel, nav: INav, ) { - if (aTagHex == null) return - - LoadAddressableNote(aTagHex = aTagHex, accountViewModel) { + LoadAddressableNote(aTagHex, accountViewModel) { it?.let { PrepareViewModelsCommunityScreen( note = it, @@ -94,7 +93,7 @@ fun CommunityScreen( DisappearingScaffold( isInvertedLayout = false, topBar = { - CommunityTopBar(note.idHex, accountViewModel, nav) + CommunityTopBar(note.address, accountViewModel, nav) }, floatingButton = { NewCommunityNoteButton(note.idHex, accountViewModel, nav) @@ -116,11 +115,11 @@ fun CommunityScreen( @Composable fun CommunityTopBar( - id: String, + id: Address, accountViewModel: AccountViewModel, nav: INav, ) { - LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote -> + LoadAddressableNote(id, accountViewModel) { baseNote -> if (baseNote != null) { TopBarExtensibleWithBackButton( title = { ShortCommunityHeader(baseNote, accountViewModel, nav) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt index 42f7023f00..158eab186c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt @@ -20,23 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable @@ -69,9 +67,9 @@ fun NewCommunityNoteButton( containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - painter = painterRes(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 2), contentDescription = stringRes(id = R.string.new_community_note), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt index a9d06b3041..bd7db1129b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent class CommunityFeedFilterSubAssembler( @@ -32,16 +33,23 @@ class CommunityFeedFilterSubAssembler( ) : SingleSubEoseManager(client, allKeys) { override fun updateFilter( keys: List, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List { if (keys.isEmpty()) return emptyList() - return keys.mapNotNull { + return keys.flatMap { val commEvent = it.community.event if (commEvent is CommunityDefinitionEvent) { - filterCommunityPosts(commEvent, since) + val relays = + commEvent.relayUrls().ifEmpty { null } + ?: LocalCache.relayHints.hintsForAddress(commEvent.addressTag()).ifEmpty { null } + ?: it.community.relayUrls() + + relays.toSet().map { + filterCommunityPosts(it, commEvent, since?.get(it)?.time) + } } else { - null + emptyList() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt index a9ec56023d..e69a2dad92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class CommunityQueryState( @@ -37,15 +37,11 @@ class CommunityFilterAssembler( CommunityFeedFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index 7cd506d89d..c728a95267 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -20,24 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent fun filterCommunityPosts( + relay: NormalizedRelayUrl, community: CommunityDefinitionEvent, - since: Map?, -): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( authors = community.moderators().map { it.pubKey }.plus(community.pubKey), tags = mapOf("a" to listOf(community.addressTag())), - kinds = listOf(CommunityPostApprovalEvent.KIND), + kinds = CommunityPostApprovalEvent.KIND_LIST, limit = 500, since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt index a3ae7c7b8d..2f87c20798 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.ClickableNote import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.RenderLongFormThumb -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderChannelThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderPublicChatChannelThumb import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.RenderFollowSetThumb import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.RenderLiveActivityThumb import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.RenderCommunitiesThumb @@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -203,7 +203,7 @@ private fun RenderNoteRow( when (baseNote.event) { is LiveActivitiesEvent -> RenderLiveActivityThumb(baseNote, accountViewModel, nav) is CommunityDefinitionEvent -> RenderCommunitiesThumb(baseNote, accountViewModel, nav) - is ChannelCreateEvent -> RenderChannelThumb(baseNote, accountViewModel, nav) + is ChannelCreateEvent -> RenderPublicChatChannelThumb(baseNote, accountViewModel, nav) is AppDefinitionEvent -> RenderContentDVMThumb(baseNote, accountViewModel, nav) is FollowListEvent -> RenderFollowSetThumb(baseNote, accountViewModel, nav) is LongTextNoteEvent -> RenderLongFormThumb(baseNote, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index b037e6d3b3..dfccfe8183 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -69,9 +69,9 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.TabItem @@ -83,7 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 18fab1ffbe..2101fe7b6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -23,26 +23,47 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithoutRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun DiscoveryTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultDiscoveryFollowList .collectAsStateWithLifecycle() - FollowListWithoutRoutes( + FollowList( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, - accountViewModel, - ) { listName -> - accountViewModel.account.settings.changeDefaultDiscoveryFollowList(listName.code) - } + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultDiscoveryFollowList, + ) } } + +@Composable +private fun FollowList( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt index f7de6508a5..a8d7a427ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -22,12 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag class DiscoveryQueryState( val account: Account, + val feedStates: AccountFeedContentStates, val scope: CoroutineScope, ) @@ -36,19 +38,16 @@ class DiscoveryFilterAssembler( ) : ComposeSubscriptionManager() { val group = listOf( - DiscoveryFollowsDiscoverySubAssembler(client, ::allKeys), - MixGeohashHashtagsDiscoverySubAssembler(client, ::allKeys), + DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, ::allKeys), + DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, ::allKeys), + DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt index b156df1cf1..5a2a10b0c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -43,7 +43,7 @@ fun DiscoveryFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel.account) { - DiscoveryQueryState(accountViewModel.account, accountViewModel.viewModelScope) + DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt new file mode 100644 index 0000000000..37c2242449 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.makeFollowSetsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.makeLiveActivitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + + return makeFollowSetsFilter(feedSettings, since, key.feedStates.discoverFollowSets.lastNoteCreatedAtIfFilled()) + + makeLiveActivitiesFilter(feedSettings, since, key.feedStates.discoverLive.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: DiscoveryQueryState) = key.account.userProfile() + + override fun list(key: DiscoveryQueryState) = key.listName() + + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun DiscoveryQueryState.listName() = listNameFlow().value + + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: DiscoveryQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + combine( + key.feedStates.discoverFollowSets.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverLive.lastNoteCreatedAtWhenFullyLoaded, + ) { + Any() + }.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt index 6f6b41e812..00d94583e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt @@ -20,37 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByFollows -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.makeLongFormFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.makeContentDVMsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.makeClassifiedsFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -import kotlin.collections.flatten -class DiscoveryFollowsDiscoverySubAssembler( +class DiscoveryLongFormClassifiedsAndDVMSubAssembler1( client: NostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: DiscoveryQueryState, - since: Map?, - ): List? = updateFilter(key.followsPerRelay(), key.followLists(), since) + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + + return makeLongFormFilter(feedSettings, since, key.feedStates.discoverReads.lastNoteCreatedAtIfFilled()) + + makeClassifiedsFilter(feedSettings, since, key.feedStates.discoverMarketplace.lastNoteCreatedAtIfFilled()) + + makeContentDVMsFilter(feedSettings, since, key.feedStates.discoverDVMs.lastNoteCreatedAtIfFilled()) + } override fun user(key: DiscoveryQueryState) = key.account.userProfile() @@ -60,32 +60,10 @@ class DiscoveryFollowsDiscoverySubAssembler( fun DiscoveryQueryState.listName() = listNameFlow().value - fun DiscoveryQueryState.followListsFlow() = account.liveDiscoveryFollowLists - - fun DiscoveryQueryState.followLists() = followListsFlow().value - - fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryListAuthorsPerRelay + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value - fun updateFilter( - follows: Map>?, - followLists: Account.LiveFollowList?, - since: Map?, - ): List? { - if (follows != null && follows.isEmpty()) return null - - return listOfNotNull( - filterClassifiedsByFollows(follows, since), - filterFollowSetsByFollows(follows, since), - filterLongFormByFollows(follows, since), - filterPublicChatsByFollows(follows, since), - filterContentDVMsByFollows(follows, since), - filterLiveActivitiesByFollows(follows, followLists?.authorsPlusMe, since), - filterCommunitiesByFollows(follows, since), - ).flatten() - } - val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) @@ -100,7 +78,18 @@ class DiscoveryFollowsDiscoverySubAssembler( } }, key.scope.launch(Dispatchers.Default) { - key.followsPerRelayFlow().sample(5000).collectLatest { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + combine( + key.feedStates.discoverReads.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverDVMs.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverMarketplace.lastNoteCreatedAtWhenFullyLoaded, + ) { + Any() + }.sample(1000).collectLatest { invalidateFilters() } }, @@ -113,7 +102,7 @@ class DiscoveryFollowsDiscoverySubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt new file mode 100644 index 0000000000..ef4fbb0119 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.makePublicChatsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class DiscoveryPublicChatsAndCommunitiesSubAssembler3( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + return makePublicChatsFilter(feedSettings, since, key.feedStates.discoverPublicChats.lastNoteCreatedAtIfFilled()) + + makeCommunitiesFilter(feedSettings, since, key.feedStates.discoverCommunities.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: DiscoveryQueryState) = key.account.userProfile() + + override fun list(key: DiscoveryQueryState) = key.listName() + + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun DiscoveryQueryState.listName() = listNameFlow().value + + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: DiscoveryQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + combine( + key.feedStates.discoverPublicChats.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverCommunities.lastNoteCreatedAtWhenFullyLoaded, + ) { + Any() + }.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt deleted file mode 100644 index 51660df20f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunityPostsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunityPostsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByHashtag -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class MixGeohashHashtagsDiscoverySubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: DiscoveryQueryState, - since: Map?, - ): List? = updateFilter(key.followLists()?.hashtags, key.followLists()?.geotags, since) - - fun updateFilter( - hashtags: Set?, - geotags: Set?, - since: Map?, - ): List? { - val hashtagFilters = - if (hashtags != null && !hashtags.isEmpty()) { - listOfNotNull( - filterClassifiedsByHashtag(hashtags, since), - filterPublicChatsByHashtag(hashtags, since), - filterFollowSetsByHashtag(hashtags, since), - filterLongFormByHashtag(hashtags, since), - filterContentDVMsByHashtag(hashtags, since), - filterLiveActivitiesByHashtag(hashtags, since), - filterCommunityPostsByHashtag(hashtags, since), - ).flatten() - } else { - emptyList() - } - - val geoHashFilters = - if (geotags != null && !geotags.isEmpty()) { - listOfNotNull( - filterClassifiedsByGeohash(geotags, since), - filterPublicChatsByGeohash(geotags, since), - filterFollowSetsByGeohash(geotags, since), - filterLongFormByGeohash(geotags, since), - filterContentDVMsByGeohash(geotags, since), - filterLiveActivitiesByGeohash(geotags, since), - filterCommunityPostsByGeohash(geotags, since), - ).flatten() - } else { - emptyList() - } - - return hashtagFilters + geoHashFilters - } - - override fun user(key: DiscoveryQueryState) = key.account.userProfile() - - fun DiscoveryQueryState.followListsFlow() = account.liveDiscoveryFollowLists - - fun DiscoveryQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: DiscoveryQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index 868092d8b5..0eea42a298 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -27,14 +27,16 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverLongFormFeedFilter( val account: Account, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + override fun limit() = 100 + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = @@ -56,11 +58,9 @@ open class DiscoverLongFormFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt index 3661fb5f49..317275f2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.types.LongFormHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt new file mode 100644 index 0000000000..a6c14d36d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeLongFormFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterLongFormByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterLongFormByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterLongFormGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterLongFormByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterLongFormByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterLongFormByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt new file mode 100644 index 0000000000..44413e7ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLongFormAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(LongTextNoteEvent.KIND.toString()), + ), + limit = 30, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 30, + since = since, + ), + ), + ) +} + +fun filterLongFormByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterLongFormAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt new file mode 100644 index 0000000000..eb2d4311db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +fun filterLongFormAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(LongTextNoteEvent.KIND), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterLongFormByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLongFormAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterLongFormByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLongFormAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt new file mode 100644 index 0000000000..af7f7b963d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLongFormByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(LongTextNoteEvent.KIND.toString()), + ), + limit = 30, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 30, + since = since, + ), + ), + ) +} + +fun filterLongFormByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterLongFormByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt index e8eb237955..1fb64fd407 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt @@ -20,29 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterLongFormByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(LongTextNoteEvent.KIND), - limit = 300, - since = since, - ), - ), - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterLongFormAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterLongFormByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterLongFormByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterLongFormAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt index 88a4edd458..2346b02a7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -20,30 +20,54 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent fun filterLongFormByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("g" to geoHashes), - limit = 300, + limit = 100, since = since, ), ), ) } + +fun filterLongFormByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterLiveActivitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt index b63040209d..26dae5a7cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -20,34 +20,54 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent fun filterLongFormByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LongTextNoteEvent.KIND, - ), + Filter( + kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("t" to hashtags), - limit = 300, + limit = 200, since = since, ), ), ) } + +fun filterLongFormByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterLongFormByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt new file mode 100644 index 0000000000..4c879bbb32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterLongFormGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LongTextNoteEvent.KIND), + limit = 100, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt index fdf74d7afe..c7c4e9ca5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt @@ -23,19 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverChatFeedFilter( val account: Account, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value + override fun limit() = 100 + override fun showHiddenKey(): Boolean = account.settings.defaultDiscoveryFollowList.value == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || @@ -46,16 +47,12 @@ open class DiscoverChatFeedFilter( val params = buildFilterParams(account) val allChannelNotes = - LocalCache.channels.mapNotNullIntoSet { _, channel -> - if (channel is PublicChatChannel) { - val note = LocalCache.getNoteIfExists(channel.idHex) - val noteEvent = note?.event + LocalCache.publicChatChannels.mapNotNullIntoSet { _, channel -> + val note = LocalCache.getNoteIfExists(channel.idHex) + val noteEvent = note?.event - if (noteEvent == null || params.match(noteEvent)) { - note - } else { - null - } + if (noteEvent == null || params.match(noteEvent)) { + note } else { null } @@ -67,11 +64,9 @@ open class DiscoverChatFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { @@ -81,7 +76,7 @@ open class DiscoverChatFeedFilter( // note event here will never be null val noteEvent = note.event if (noteEvent is ChannelCreateEvent && params.match(noteEvent)) { - if ((LocalCache.getChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) { + if ((LocalCache.getPublicChatChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) { note } else { null @@ -93,7 +88,7 @@ open class DiscoverChatFeedFilter( if (channel != null && (channelEvent == null || (channelEvent is ChannelCreateEvent && params.match(channelEvent))) ) { - if ((LocalCache.getChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) { + if ((LocalCache.getPublicChatChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) { channel } else { null @@ -110,7 +105,7 @@ open class DiscoverChatFeedFilter( override fun sort(collection: Set): List { val lastNote = collection.associateWith { note -> - LocalCache.getChannelIfExists(note.idHex)?.lastNoteCreatedAt ?: 0 + LocalCache.getPublicChatChannelIfExists(note.idHex)?.lastNote?.createdAt() ?: 0 } return collection diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt index f4f8ddbb70..0d4c93a036 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt @@ -41,17 +41,24 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -69,33 +76,31 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun RenderChannelThumb( +fun RenderPublicChatChannelThumb( baseNote: Note, accountViewModel: AccountViewModel, nav: INav, ) { val noteEvent = baseNote.event as? ChannelCreateEvent ?: return - LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) { - RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) + LoadPublicChatChannel(baseNote.idHex, accountViewModel) { + RenderPublicChatChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) } } @Composable -fun RenderChannelThumb( +fun RenderPublicChatChannelThumb( baseNote: Note, - channel: Channel, + channel: PublicChatChannel, accountViewModel: AccountViewModel, nav: INav, ) { val channelUpdates by observeChannel(channel, accountViewModel) + val publicChat = channelUpdates?.channel as PublicChatChannel - val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } - val description = remember(channelUpdates) { channelUpdates?.channel?.summary()?.ifBlank { null } } - var cover by - remember(channelUpdates) { - mutableStateOf(channelUpdates?.channel?.profilePicture()?.ifBlank { null }) - } + val name = remember(channelUpdates) { publicChat.toBestDisplayName() } + val description = remember(channelUpdates) { publicChat.summary()?.ifBlank { null } } + var cover by remember(channelUpdates) { mutableStateOf(publicChat.profilePicture()?.ifBlank { null }) } var participantUsers by remember(baseNote) { @@ -106,9 +111,20 @@ fun RenderChannelThumb( LaunchedEffect(key1 = channelUpdates) { launch(Dispatchers.IO) { - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors val allParticipants = ParticipantListBuilder() .followsThatParticipateOn(baseNote, followingKeySet) @@ -116,7 +132,7 @@ fun RenderChannelThumb( val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt new file mode 100644 index 0000000000..3cc60bb857 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePublicChatsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPublicChatsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPublicChatsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPublicChatsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPublicChatsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPublicChatsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPublicChatsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt new file mode 100644 index 0000000000..437237ba11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPublicChatsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(ChannelCreateEvent.KIND.toString(), ChannelMetadataEvent.KIND.toString(), ChannelMessageEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + return communitySet.set + .mapNotNull { + filterPublicChatsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt new file mode 100644 index 0000000000..ce6db72046 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun filterPublicChatsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPublicChatsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPublicChatsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPublicChatsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt new file mode 100644 index 0000000000..020916f4cf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPublicChatsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(ChannelCreateEvent.KIND.toString(), ChannelMetadataEvent.KIND.toString(), ChannelMessageEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPublicChatsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt index 597fe822bc..2ee9441872 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt @@ -20,29 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterPublicChatsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ChannelMessageEvent.KIND), - limit = 500, - since = since, - ), - ), - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPublicChatsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterPublicChatsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterPublicChatsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterPublicChatsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt index ecd45458c0..778834c649 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -20,27 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterPublicChatsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf( ChannelCreateEvent.KIND, @@ -54,3 +56,24 @@ fun filterPublicChatsByGeohash( ), ) } + +fun filterPublicChatsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPublicChatsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt index 7a4570c223..a2d147ad12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -20,28 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterPublicChatsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf( ChannelCreateEvent.KIND, @@ -55,3 +57,24 @@ fun filterPublicChatsByHashtag( ), ) } + +fun filterPublicChatsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPublicChatsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt new file mode 100644 index 0000000000..defd9b5022 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.utils.TimeUtils + +fun filterPublicChatsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ), + limit = 30, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = + listOf( + ChannelMessageEvent.KIND, + ), + limit = 50, + since = since ?: TimeUtils.oneWeekAgo(), + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index 8f0dd3b7b3..07d278ff2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -26,9 +26,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverFollowSetsFeedFilter( val account: Account, @@ -56,11 +56,9 @@ open class DiscoverFollowSetsFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt index 298352ea46..dda0079c93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -35,33 +34,35 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -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.observeNoteAndMap -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -84,13 +85,13 @@ fun RenderFollowSetThumb( val noteEvent = it.event as? FollowListEvent FollowSetCard( - name = noteEvent?.nameOrTitle()?.ifBlank { null } ?: noteEvent?.dTag() ?: "", + name = noteEvent?.title()?.ifBlank { null } ?: noteEvent?.dTag() ?: "", media = noteEvent?.image()?.ifBlank { null }, description = noteEvent?.description(), users = accountViewModel .loadUsersSync( - noteEvent?.pubKeys() ?: emptyList(), + noteEvent?.followIds() ?: emptyList(), ).toImmutableList(), ) } @@ -147,17 +148,21 @@ fun RenderFollowSetThumb( contentAlignment = Alignment.BottomStart, ) { card.media?.let { - AsyncImage( - model = it, - contentDescription = null, + MyAsyncImage( + imageUrl = it, + contentDescription = + stringRes( + R.string.preview_card_image_for, + it, + ), contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 21f / 9f) - .clip(QuoteBorder), + mainImageModifier = Modifier, + loadedImageModifier = FollowSetImageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, ) - } ?: run { DisplayAuthorBanner(baseNote, accountViewModel) } + } ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) } Gallery(card.users, Modifier.padding(Size10dp), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt new file mode 100644 index 0000000000..44cd0f58e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeFollowSetsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterFollowSetsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterFollowSetsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterFollowSetsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterFollowSetsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterFollowSetsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterFollowSetsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt new file mode 100644 index 0000000000..865adb4d33 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterFollowSetsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(FollowListEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt new file mode 100644 index 0000000000..bc1139bc23 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +fun filterFollowSetsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterFollowSetsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterFollowSetsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterFollowSetsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt new file mode 100644 index 0000000000..fe8e661bf4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterFollowSetsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(FollowListEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterFollowSetsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt index 129ae8bf34..063cfe7867 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt @@ -20,29 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterFollowSetsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FollowListEvent.KIND), - limit = 300, - since = since, - ), - ), - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterFollowSetsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterFollowSetsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterFollowSetsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterFollowSetsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt index dfe845e0cd..b6ff52cc42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt @@ -20,26 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(LongTextNoteEvent.KIND), + Filter( + kinds = listOf(FollowListEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, since = since, @@ -47,3 +49,24 @@ fun filterFollowSetsByGeohash( ), ) } + +fun filterFollowSetsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterFollowSetsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt index 7fc277b392..1301091733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt @@ -20,30 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LongTextNoteEvent.KIND, - ), + Filter( + kinds = listOf(FollowListEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, since = since, @@ -51,3 +50,24 @@ fun filterFollowSetsByHashtag( ), ) } + +fun filterFollowSetsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterFollowSetsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt similarity index 55% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt index 8770b335b5..661e9ca700 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -18,32 +18,31 @@ * 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.watchers +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -fun filterQuotesToNotes( - notes: List, - since: Map?, -): List? { - if (notes.isEmpty()) return null +fun filterFollowSetsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, filter = - SincePerRelayFilter( - kinds = listOf(TextNoteEvent.KIND), - tags = mapOf("q" to notes.map { it.idHex }.sorted()), + Filter( + kinds = listOf(FollowListEvent.KIND), + limit = 100, since = since, - // Max amount of "replies" to download on a specific event. - limit = 1000, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 3437801917..bf2f1db737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -24,10 +24,17 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag @@ -38,13 +45,15 @@ open class DiscoverLiveFeedFilter( open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + override fun limit() = 50 + override fun showHiddenKey(): Boolean = followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { - val allChannelNotes = LocalCache.channels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) } - val allMessageNotes = LocalCache.channels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten() + val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) } + val allMessageNotes = LocalCache.liveChatChannels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten() val notes = innerApplyFilter(allChannelNotes + allMessageNotes) @@ -55,11 +64,9 @@ open class DiscoverLiveFeedFilter( protected open fun innerApplyFilter(collection: Collection): Set { val filterParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return collection.filterTo(HashSet()) { @@ -69,8 +76,21 @@ open class DiscoverLiveFeedFilter( } override fun sort(collection: Set): List { + val topFilter = account.liveDiscoveryFollowLists.value + val discoveryTopFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + val followingKeySet = - account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors + discoveryTopFilterAuthors ?: account.kind3FollowList.flow.value.authors val counter = ParticipantListBuilder() val participantCounts = @@ -91,11 +111,11 @@ open class DiscoverLiveFeedFilter( ).reversed() } - fun convertStatusToOrder(status: String?): Int = + fun convertStatusToOrder(status: StatusTag.STATUS?): Int = when (status) { - StatusTag.STATUS.LIVE.code -> 2 - StatusTag.STATUS.PLANNED.code -> 1 - StatusTag.STATUS.ENDED.code -> 0 + StatusTag.STATUS.LIVE -> 2 + StatusTag.STATUS.PLANNED -> 1 + StatusTag.STATUS.ENDED -> 0 else -> 0 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt index 41dfa2f864..b04f7b8f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt @@ -45,14 +45,22 @@ 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.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.OfflineFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag @@ -60,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag @@ -71,13 +80,14 @@ import kotlinx.coroutines.launch @Immutable data class LiveActivityCard( + val id: Address?, val name: String, val cover: String?, val media: String?, val subject: String?, val content: String?, val participants: ImmutableList, - val status: String?, + val status: StatusTag.STATUS?, val starts: Long?, ) @@ -91,6 +101,7 @@ fun RenderLiveActivityThumb( val noteEvent = it.event as? LiveActivitiesEvent LiveActivityCard( + id = noteEvent?.address(), name = noteEvent?.dTag() ?: "", cover = noteEvent?.image()?.ifBlank { null }, media = noteEvent?.streaming(), @@ -142,7 +153,7 @@ fun RenderLiveActivityThumb( Box(Modifier.padding(10.dp)) { CrossfadeIfEnabled(targetState = card.status, label = "RenderLiveActivityThumb", accountViewModel = accountViewModel) { when (it) { - StatusTag.STATUS.LIVE.code -> { + StatusTag.STATUS.LIVE -> { val url = card.media if (url.isNullOrBlank()) { LiveFlag() @@ -156,10 +167,10 @@ fun RenderLiveActivityThumb( } } } - StatusTag.STATUS.ENDED.code -> { + StatusTag.STATUS.ENDED -> { EndedFlag() } - StatusTag.STATUS.PLANNED.code -> { + StatusTag.STATUS.PLANNED -> { ScheduledFlag(card.starts) } else -> { @@ -184,15 +195,19 @@ fun RenderLiveActivityThumb( Spacer(modifier = DoubleVertSpacer) - ChannelHeader( - channelHex = baseNote.idHex, - showVideo = false, - showFlag = false, - sendToChannel = true, - modifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) + baseNote.address()?.let { + LoadLiveActivityChannel(it, accountViewModel) { + LiveActivitiesChannelHeader( + baseChannel = it, + showVideo = false, + showFlag = false, + sendToChannel = true, + modifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } } @@ -222,9 +237,19 @@ fun LoadParticipants( val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> emptySet() + } val allParticipants = ParticipantListBuilder() @@ -233,7 +258,7 @@ fun LoadParticipants( val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = ParticipantListBuilder() .followsThatParticipateOn(baseNote, allFollows) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt new file mode 100644 index 0000000000..16e3cfca26 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeLiveActivitiesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterLiveActivitiesByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterLiveActivitiesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterLiveActivitiesGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterLiveActivitiesByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterLiveActivitiesByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterLiveActivitiesByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt new file mode 100644 index 0000000000..b102e7b24b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLiveActivitiesAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(LiveActivitiesChatMessageEvent.KIND.toString(), LiveActivitiesEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt new file mode 100644 index 0000000000..4f152e76ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +fun filterLiveActivitiesAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + // authors are participating in the live event. + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("p" to authorList), + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLiveActivitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterLiveActivitiesByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLiveActivitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt new file mode 100644 index 0000000000..85dd03f564 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLiveActivitiesByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(LiveActivitiesChatMessageEvent.KIND.toString(), LiveActivitiesEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterLiveActivitiesByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt index f69c59490b..c635ff1478 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt @@ -20,48 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterLiveActivitiesByFollows( - follows: Map>?, - followKeys: Set?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOfNotNull( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = - listOf( - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), - limit = 300, - since = since, - ), - ), - followKeys?.let { - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - tags = mapOf("p" to it.toList()), - kinds = listOf(LiveActivitiesEvent.KIND), - limit = 100, - since = since, - ), - ) - }, - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterLiveActivitiesAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterLiveActivitiesByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterLiveActivitiesByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterLiveActivitiesAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt index 8ad8955436..7c2d11dbc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -20,31 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent fun filterLiveActivitiesByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, since = since, @@ -52,3 +50,24 @@ fun filterLiveActivitiesByGeohash( ), ) } + +fun filterLiveActivitiesByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterLiveActivitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt index 3b67b868c3..5367d860b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -20,32 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent fun filterLiveActivitiesByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, since = since, @@ -53,3 +51,24 @@ fun filterLiveActivitiesByHashtag( ), ) } + +fun filterLiveActivitiesByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterLiveActivitiesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt new file mode 100644 index 0000000000..8932ad9e23 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterLiveActivitiesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set + .map { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 30, + since = since ?: TimeUtils.oneWeekAgo(), + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + limit = 50, + since = since ?: TimeUtils.oneDayAgo(), + ), + ), + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index 460f8d3ba6..e1089042f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -47,9 +47,16 @@ 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.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -197,15 +204,26 @@ fun LoadModerators( } } - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val discoveryTopFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = discoveryTopFilterAuthors val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt index c4b3ad02e5..6d28ea78dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt @@ -25,9 +25,10 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -36,6 +37,8 @@ open class DiscoverCommunityFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value + override fun limit() = 150 + override fun showHiddenKey(): Boolean = account.settings.defaultDiscoveryFollowList.value == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || @@ -44,18 +47,16 @@ open class DiscoverCommunityFeedFilter( override fun feed(): List { val filterParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) // Here we only need to look for CommunityDefinition Events val notes = LocalCache.addressables.mapNotNullIntoSet { key, note -> val noteEvent = note.event - if (noteEvent == null && shouldInclude(Address.Companion.parse(key), filterParams)) { + if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) { // send unloaded communities to the screen note } else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) { @@ -73,11 +74,9 @@ open class DiscoverCommunityFeedFilter( protected open fun innerApplyFilter(collection: Collection): Set { // here, we need to look for CommunityDefinition in new collection AND new CommunityDefinition from Post Approvals val filterParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return collection @@ -91,7 +90,7 @@ open class DiscoverCommunityFeedFilter( val definitionNote = LocalCache.getOrCreateAddressableNote(it) val definitionEvent = definitionNote.event - if (definitionEvent == null && shouldInclude(it, filterParams)) { + if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) { definitionNote } else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) { definitionNote @@ -109,7 +108,8 @@ open class DiscoverCommunityFeedFilter( private fun shouldInclude( aTag: Address?, params: FilterByListParams, - ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag) + comingFrom: List = emptyList(), + ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom) override fun sort(collection: Set): List { val lastNote = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt new file mode 100644 index 0000000000..77316bb8f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeCommunitiesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterCommunitiesByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterCommunitiesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterCommunitiesGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterCommunitiesByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterCommunitiesByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterCommunitiesByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt new file mode 100644 index 0000000000..7c2112abf1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterCommunitiesAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + ids = communityList, + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterCommunitiesAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt new file mode 100644 index 0000000000..934aaba585 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterCommunitiesAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterCommunitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterCommunitiesByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterCommunitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt new file mode 100644 index 0000000000..fe3872fd80 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterClassifiedsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = listOf(CommunityDefinitionEvent.KIND), + ids = listOf(community), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterClassifiedsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt index 54bfbb8b90..106dbfe5e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt @@ -20,34 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterCommunitiesByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - 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 = since, - ), - ), - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterCommunitiesAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterCommunitiesByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterCommunitiesByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterCommunitiesAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt similarity index 52% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByGeohash.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt index 11dad756f8..6c9939b580 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -20,35 +20,54 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -fun filterCommunityPostsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null +fun filterCommunitiesByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - CommunityDefinitionEvent.KIND, - CommunityPostApprovalEvent.KIND, - ), + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("g" to geoHashes), - limit = 300, + limit = 100, since = since, ), ), ) } + +fun filterCommunitiesByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterCommunitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt new file mode 100644 index 0000000000..0a20b39fde --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterCommunitiesByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set?, + since: Long?, +): List { + if (hashtags == null || hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterCommunitiesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index 72fdf25b64..6798e81f6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -20,36 +20,42 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils -fun filterCommunityPostsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null +fun filterCommunitiesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() - - return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = - listOf( - CommunityDefinitionEvent.KIND, - CommunityPostApprovalEvent.KIND, - ), - tags = mapOf("t" to hashtags), - limit = 300, - since = since, - ), - ), - ) + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(CommunityPostApprovalEvent.KIND), + limit = 100, + since = since ?: TimeUtils.oneWeekAgo(), + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 3f5fda703c..34a00ce158 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -35,7 +34,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment.Companion.BottomStart import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight @@ -44,19 +42,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp +import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.bitcoinColor @@ -87,25 +85,28 @@ fun RenderContentDVMThumb( onImage = { card.cover?.let { Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxSize() - .clip(QuoteBorder), + mainImageModifier = Modifier, + loadedImageModifier = SimpleImageBorder, + accountViewModel = accountViewModel, + onLoadingBackground = { + baseNote.author?.let { author -> + BannerImage(author, SimpleImageBorder, accountViewModel) + } + }, + onError = { + baseNote.author?.let { author -> + BannerImage(author, SimpleImageBorder, accountViewModel) + } + }, ) } } ?: run { - baseNote.author?.let { - BannerImage( - it, - Modifier - .fillMaxSize() - .clip(QuoteBorder), - accountViewModel, - ) + baseNote.author?.let { author -> + BannerImage(author, SimpleImageBorder, accountViewModel) } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index d4aaa581b0..2a697c56cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,6 +38,8 @@ open class DiscoverNIP89FeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + override fun limit() = 50 + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = @@ -56,11 +58,9 @@ open class DiscoverNIP89FeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) fun acceptDVM(note: Note): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt new file mode 100644 index 0000000000..cd0e1de3c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeContentDVMsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterContentDVMsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterContentDVMsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterContentDVMsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterContentDVMsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterContentDVMsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt new file mode 100644 index 0000000000..89dfd866b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(AppDefinitionEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt new file mode 100644 index 0000000000..657367a957 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlin.collections.flatten + +fun filterContentDVMsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300")), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterContentDVMsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterContentDVMsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterContentDVMsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt new file mode 100644 index 0000000000..82c25e9b60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(AppDefinitionEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt index 83dce49934..818c7401d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt @@ -20,29 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterContentDVMsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = listOf(AppDefinitionEvent.KIND), - limit = 300, - tags = mapOf("k" to listOf("5300")), - since = since, - ), - ), - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterContentDVMsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterContentDVMsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterContentDVMsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterContentDVMsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt index 9b04759007..55bd2db38d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -20,25 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun filterContentDVMsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "g" to geoHashes), limit = 300, @@ -47,3 +49,24 @@ fun filterContentDVMsByGeohash( ), ) } + +fun filterContentDVMsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterContentDVMsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt index 076b9d6d34..2920bd956d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -20,26 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun filterContentDVMsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "t" to hashtags), limit = 300, @@ -48,3 +50,24 @@ fun filterContentDVMsByHashtag( ), ) } + +fun filterContentDVMsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterContentDVMsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt new file mode 100644 index 0000000000..395ca8d121 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300")), + limit = 30, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt index c607e9b296..df2a99c3a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -118,7 +118,7 @@ fun InnerRenderClassifiedsThumb( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - } ?: run { DisplayAuthorBanner(note, accountViewModel) } + } ?: run { DisplayAuthorBanner(note, accountViewModel, Modifier.fillMaxSize()) } Row( Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index ecc5fb9971..66793384a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent open class DiscoverMarketplaceFeedFilter( @@ -36,6 +36,8 @@ open class DiscoverMarketplaceFeedFilter( open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + override fun limit() = 150 + override fun showHiddenKey(): Boolean = followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) @@ -55,11 +57,9 @@ open class DiscoverMarketplaceFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 9466877f0f..25f64e65f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -22,11 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds import android.net.Uri import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -37,20 +34,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -60,16 +48,14 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -88,15 +74,13 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.launch @@ -152,74 +136,39 @@ fun NewProductScreen( ) { WatchAndLoadMyEmojiList(accountViewModel) - var showRelaysDialog by remember { mutableStateOf(false) } - Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterRes(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } + PostingTopBar( + titleRes = R.string.new_product, + isActive = postViewModel::canPost, + onCancel = { + try { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() } - PostButton( - onPost = { - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendPostSync() - nav.popBack() - postViewModel.cancel() - } - }, - isActive = postViewModel.canPost(), + } catch (e: SignerExceptions.ReadOnlyException) { + // do nothing. + } + }, + onPost = { + try { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendPostSync() + nav.popBack() + postViewModel.cancel() + } + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, ) } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendDraftSync() - nav.popBack() - postViewModel.cancel() - } - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> - if (showRelaysDialog) { - RelaySelectionDialogEasy( - preSelectedList = postViewModel.relayList ?: persistentListOf(), - onClose = { showRelaysDialog = false }, - onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, - accountViewModel = accountViewModel, - nav = nav, - ) - } Surface( modifier = Modifier @@ -327,11 +276,11 @@ private fun NewProductBody( postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( lud16, - accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account.userProfile(), accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.insertAtCursor(it) postViewModel.wantsInvoice = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index d9fce20a7e..33b35373a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -164,7 +165,7 @@ open class NewProductViewModel : var wantsZapraiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) - var relayList by mutableStateOf?>(null) + var relayList by mutableStateOf?>(null) fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() @@ -193,14 +194,12 @@ open class NewProductViewModel : if (noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } @@ -386,7 +385,6 @@ open class NewProductViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, alt, contentWarningReason, MediaCompressor.intToCompressorQuality(mediaQuality), @@ -474,17 +472,9 @@ open class NewProductViewModel : fun reloadRelaySet() { val account = accountViewModel?.account ?: return - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } + account.outboxRelays.flow.value + .toImmutableList() } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { @@ -551,7 +541,7 @@ open class NewProductViewModel : viewModelScope.launch(Dispatchers.IO) { iMetaDescription.downloadAndPrepare( item.link.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) ?: false) }, ) } @@ -603,7 +593,7 @@ open class NewProductViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt new file mode 100644 index 0000000000..49ca8ca0eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeClassifiedsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterClassifiedsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterClassifiedsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterClassifiedsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterClassifiedsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterClassifiedsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterClassifiedsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt new file mode 100644 index 0000000000..e1d6664ef2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(ClassifiedsEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterClassifiedsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt new file mode 100644 index 0000000000..de27c901a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(ClassifiedsEvent.KIND), + limit = authorList.size * 10, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterClassifiedsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterClassifiedsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterClassifiedsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt new file mode 100644 index 0000000000..cffe1a0042 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(ClassifiedsEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterClassifiedsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt index 38c5d3c8f0..7bbf7535e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt @@ -20,29 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun filterClassifiedsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ClassifiedsEvent.KIND), - limit = 300, - since = since, - ), - ), - ) + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterClassifiedsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterClassifiedsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterClassifiedsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterClassifiedsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt index 9273b0028d..0c89c881b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -20,25 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent fun filterClassifiedsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, @@ -47,3 +49,24 @@ fun filterClassifiedsByGeohash( ), ) } + +fun filterClassifiedsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterClassifiedsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index a82f424ea6..9645bef0d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -20,26 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent fun filterClassifiedsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set?, + since: Long?, +): List? { + if (hashtags == null || hashtags.isEmpty()) return null - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, @@ -48,3 +50,24 @@ fun filterClassifiedsByHashtag( ), ) } + +fun filterClassifiedsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterClassifiedsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt new file mode 100644 index 0000000000..f91c5cde5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterClassifiedsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo() + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ClassifiedsEvent.KIND), + limit = 30, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index bfda8be7e2..ad48a8edb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -57,8 +57,8 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index cd8da2f71a..1aefd5a1b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -44,8 +45,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomStart import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -56,7 +57,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -66,17 +66,18 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.note.elements.customZapClick import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.RenderFeedState @@ -87,10 +88,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.NIP90ContentDiscov import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -117,10 +117,10 @@ fun DvmContentDiscoveryScreen( DvmTopBar(appDefinitionEventId, accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it)) { - LoadNote(baseNoteHex = appDefinitionEventId, accountViewModel = accountViewModel) { - it?.let { baseNote -> + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + LoadNote(baseNoteHex = appDefinitionEventId, accountViewModel = accountViewModel) { note -> + note?.let { baseNote -> WatchNoteEvent( baseNote, onNoteEventFound = { @@ -151,7 +151,7 @@ fun DvmContentDiscoveryScreen( } val onRefresh = { - accountViewModel.requestDVMContentDiscovery(noteAuthor.pubkeyHex) { + accountViewModel.requestDVMContentDiscovery(noteAuthor) { requestEventID = it } } @@ -324,16 +324,33 @@ fun FeedDVM( val card = observeAppDefinition(appDefinitionNote, accountViewModel) card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .size(Size75dp) - .clip(QuoteBorder), - ) - } ?: run { NoteAuthorPicture(appDefinitionNote, Size75dp, accountViewModel = accountViewModel, nav = nav) } + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage75Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + onError = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + appDefinitionNote.author?.let { author -> + Box(contentAlignment = BottomStart) { + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + } + } Spacer(modifier = DoubleVertSpacer) @@ -359,7 +376,7 @@ fun FeedDVM( if (invoice != null) { val context = LocalContext.current Button(onClick = { - if (accountViewModel.account.hasWalletConnectSetup()) { + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { accountViewModel.sendZapPaymentRequestFor( bolt11 = invoice, zappedNote = null, @@ -396,7 +413,7 @@ fun FeedDVM( val amountInInvoice = try { LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -592,16 +609,33 @@ fun FeedEmptyWithStatus( val card = observeAppDefinition(appDefinitionNote, accountViewModel) card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .size(Size75dp) - .clip(QuoteBorder), - ) - } ?: run { NoteAuthorPicture(appDefinitionNote, Size75dp, accountViewModel = accountViewModel, nav = nav) } + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage75Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + onError = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + appDefinitionNote.author?.let { author -> + Box(contentAlignment = BottomStart) { + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + } + } Spacer(modifier = DoubleVertSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 2440a7a649..d310456396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -21,23 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.Size34dp +import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier @Composable fun DvmTopBar( @@ -52,22 +48,34 @@ fun DvmTopBar( val card = observeAppDefinition(appDefinitionNote, accountViewModel) card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, contentScale = ContentScale.Crop, - modifier = - Modifier - .size(Size34dp) - .clip(shape = CircleShape), + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage35Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + onError = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, ) - } ?: run { NoteAuthorPicture(baseNote = appDefinitionNote, size = Size34dp, accountViewModel = accountViewModel) } + } ?: run { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + } Spacer(modifier = DoubleHorzSpacer) Text( text = card.name, - fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index b5ab39471b..34d52608ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent open class NIP90ContentDiscoveryResponseFilter( @@ -73,10 +73,8 @@ open class NIP90ContentDiscoveryResponseFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index 9d56277f29..6baf3fdf32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.navigation.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -54,7 +54,6 @@ fun GeoHashPostScreen( val context = LocalContext.current LaunchedEffect(Unit) { - postViewModel.reloadRelaySet() geohash?.let { postViewModel.newPostFor(GeohashId(it)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt index 05bedb1b83..443f99376e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text @@ -32,8 +33,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -44,25 +46,31 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton @Composable fun GeoHashScreen( - tag: String?, + tag: Route.Geohash, accountViewModel: AccountViewModel, nav: INav, ) { - if (tag == null) return + if (tag.geohash.isEmpty()) return PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) } +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun PrepareViewModelsGeoHashScreen( - tag: String, + tag: Route.Geohash, accountViewModel: AccountViewModel, nav: INav, ) { val geohashViewModel: GeoHashFeedViewModel = viewModel( - key = tag + "GeoHashFeedViewModel", - factory = GeoHashFeedViewModel.Factory(tag, accountViewModel.account), + key = tag.geohash + "GeoHashFeedViewModel", + factory = + GeoHashFeedViewModel.Factory( + tag.geohash, + accountViewModel.account.followOutboxesOrProxy.flow.value, + accountViewModel.account, + ), ) GeoHashScreen(tag, geohashViewModel, accountViewModel, nav) @@ -70,27 +78,27 @@ fun PrepareViewModelsGeoHashScreen( @Composable fun GeoHashScreen( - tag: String, + tag: Route.Geohash, feedViewModel: GeoHashFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { WatchLifecycleAndUpdateModel(feedViewModel) - GeoHashFilterAssemblerSubscription(tag, accountViewModel.dataSources().geohashes) + GeoHashFilterAssemblerSubscription(tag, accountViewModel) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - DisplayGeoTagHeader(tag, Modifier.weight(1f)) - GeoHashActionOptions(tag, accountViewModel) + DisplayGeoTagHeader(tag.geohash, Modifier.weight(1f)) + GeoHashActionOptions(tag.geohash, accountViewModel) }, popBack = nav::popBack, ) }, floatingButton = { - NewGeoPostButton(tag, accountViewModel, nav) + NewGeoPostButton(tag.geohash, accountViewModel, nav) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt index 45a8f79e86..9191b0839f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt @@ -20,21 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable @@ -52,9 +50,9 @@ fun NewGeoPostButton( containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - painter = painterRes(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 1), contentDescription = stringRes(id = R.string.new_community_note), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index cf5e46e1e3..a0f556e32b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId class GeoHashFeedFilter( val tag: String, + val relays: Set, val account: Account, val cache: LocalCache, ) : AdditiveFeedFilter() { @@ -62,7 +64,7 @@ class GeoHashFeedFilter( geoTag: String, ): Boolean = (acceptableViaHashtag(it.event, geoTag) || acceptableViaScope(it.event, geoTag)) && - !it.isHiddenFor(account.flowHiddenUsers.value) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) fun acceptableViaHashtag( @@ -78,7 +80,7 @@ class GeoHashFeedFilter( event is PollNoteEvent || event is AudioHeaderEvent ) && - event.isTaggedGeoHash(geohash) == true + event.isTaggedGeoHash(geohash) fun acceptableViaScope( event: Event?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt index 768dc650a9..bd754f7a24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt @@ -26,20 +26,23 @@ import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class GeoHashFeedViewModel( val geohash: String, + val relays: Set, val account: Account, ) : FeedViewModel( - GeoHashFeedFilter(geohash, account, LocalCache), + GeoHashFeedFilter(geohash, relays, account, LocalCache), ) { @Suppress("UNCHECKED_CAST") class Factory( val geohash: String, + val relays: Set, val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = GeoHashFeedViewModel(geohash, account) as T + override fun create(modelClass: Class): T = GeoHashFeedViewModel(geohash, relays, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index ec91356dc6..6903974de0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -36,44 +37,51 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +val PostsByGeohashKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + fun filterPostsByGeohash( geohash: String, - since: Map?, -): List = - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("g" to listOf(geohash)), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 200, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("I" to listOf(GeohashId.toScope(geohash))), - kinds = - listOf( - CommentEvent.KIND, - ), - limit = 200, - since = since, - ), - ), - ) + relays: Set, + since: SincePerRelayMap?, +): List { + val geohashesToFollowMap = mapOf("g" to listOf(geohash)) + val geohashesScoreMap = mapOf("I" to listOf(GeohashId.toScope(geohash))) + + return relays.flatMap { relay -> + val since = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = geohashesToFollowMap, + kinds = PostsByGeohashKinds, + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = geohashesScoreMap, + kinds = CommentKinds, + limit = 100, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt index c8cab8525d..1418f06432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt @@ -21,18 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class GeoHashFeedFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: GeohashQueryState, - since: Map?, - ): List? = filterPostsByGeohash(key.geohash, since) + since: SincePerRelayMap?, + ): List? = filterPostsByGeohash(key.geohash, key.relays, since) /** * Only one key per hashtag. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt index 3764b2fb53..a1d84cf27a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag class GeohashQueryState( val geohash: String, + val relays: Set, ) { val lowercaseGeohash = geohash.lowercase() } @@ -43,15 +45,11 @@ class GeoHashFilterAssembler( GeoHashFeedFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt index 62da067bb7..49bbf8ef1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt @@ -20,21 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource +import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun GeoHashFilterAssemblerSubscription( - tag: String, - dataSource: GeoHashFilterAssembler, + tag: Route.Geohash, + accountViewModel: AccountViewModel, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(tag) { - GeohashQueryState(tag) + GeohashQueryState(tag.geohash, accountViewModel.account.followOutboxesOrProxy.flow.value) } - KeyDataSourceSubscription(state, dataSource) + KeyDataSourceSubscription(state, accountViewModel.dataSources().geohashes) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index fd0934ac37..59d4e8f8f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.navigation.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -54,7 +54,6 @@ fun HashtagPostScreen( val context = LocalContext.current LaunchedEffect(Unit) { - postViewModel.reloadRelaySet() hashtag?.let { postViewModel.newPostFor(HashtagId(it)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index fb306881a9..d8fbb9dc2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag +import android.annotation.SuppressLint import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,8 +38,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingHashtag import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal.HashtagFeedViewModel @@ -49,27 +51,29 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun HashtagScreen( - tag: String?, + tag: Route.Hashtag, accountViewModel: AccountViewModel, nav: INav, ) { - if (tag == null) return + if (tag.hashtag.isEmpty()) return PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) } +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun PrepareViewModelsHashtagScreen( - tag: String, + tag: Route.Hashtag, accountViewModel: AccountViewModel, nav: INav, ) { val hashtagFeedViewModel: HashtagFeedViewModel = viewModel( - key = tag + "HashtagFeedViewModel", + key = tag.hashtag + "HashtagFeedViewModel", factory = HashtagFeedViewModel.Factory( - tag, + tag.hashtag, + accountViewModel.account.followOutboxesOrProxy.flow.value, accountViewModel.account, ), ) @@ -79,27 +83,27 @@ fun PrepareViewModelsHashtagScreen( @Composable fun HashtagScreen( - tag: String, + tag: Route.Hashtag, feedViewModel: HashtagFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { WatchLifecycleAndUpdateModel(feedViewModel) - HashtagFilterAssemblerSubscription(tag, accountViewModel.dataSources().hashtags) + HashtagFilterAssemblerSubscription(tag, accountViewModel) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - Text("#$tag", modifier = Modifier.weight(1f)) - HashtagActionOptions(tag, accountViewModel) + Text("#${tag.hashtag}", modifier = Modifier.weight(1f)) + HashtagActionOptions(tag.hashtag, accountViewModel) }, popBack = nav::popBack, ) }, floatingButton = { - NewHashtagPostButton(tag, accountViewModel, nav) + NewHashtagPostButton(tag.hashtag, accountViewModel, nav) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt index fa16b2a52e..287971b8a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt @@ -20,21 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable @@ -52,9 +50,9 @@ fun NewHashtagPostButton( containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - painter = painterRes(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 3), contentDescription = stringRes(id = R.string.new_community_note), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 265c8bf3f1..be5a8f4834 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -41,6 +42,7 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId class HashtagFeedFilter( val tag: String, + val relays: Set, val account: Account, val cache: LocalCache, ) : AdditiveFeedFilter() { @@ -64,7 +66,7 @@ class HashtagFeedFilter( hashTag: String, ): Boolean = (acceptableViaHashtag(it.event, hashTag) || acceptableViaScope(it.event, hashTag)) && - !it.isHiddenFor(account.flowHiddenUsers.value) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) fun acceptableViaHashtag( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt index 07a5c3cac3..b1b4879417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt @@ -26,20 +26,23 @@ import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class HashtagFeedViewModel( val hashtag: String, + val relays: Set, val account: Account, ) : FeedViewModel( - HashtagFeedFilter(hashtag, account, LocalCache), + HashtagFeedFilter(hashtag, relays, account, LocalCache), ) { @Suppress("UNCHECKED_CAST") class Factory( val hashtag: String, + val relays: Set, val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = HashtagFeedViewModel(hashtag, account) as T + override fun create(modelClass: Class): T = HashtagFeedViewModel(hashtag, relays, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index d5983195d2..5b0c37659b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -20,14 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -39,58 +40,67 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +val PostsByHashtagsKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val PostsByHashtagKinds2 = + listOf( + InteractiveStorySceneEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ) + fun filterPostsByHashtags( hashtag: String, - since: Map?, -): List { - val hashtagsToFollow = hashtagAlts(hashtag).toList() + relays: Set, + since: SincePerRelayMap?, +): List { + val hashtagsToFollowMap = mapOf("t" to hashtagAlts(hashtag).sorted()) + val hashtagScoreMap = mapOf("I" to listOf(HashtagId.toScope(hashtag))) - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 400, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - InteractiveStorySceneEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - ), - limit = 100, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("I" to listOf(HashtagId.toScope(hashtag))), - kinds = listOf(CommentEvent.KIND), - limit = 200, - since = since, - ), - ), - ) + return relays.flatMap { relay -> + val since = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagsToFollowMap, + kinds = PostsByHashtagsKinds, + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagsToFollowMap, + kinds = PostsByHashtagKinds2, + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagScoreMap, + kinds = CommentKinds, + limit = 200, + since = since, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt index 5ceaa226ac..b59790f2c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt @@ -21,18 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class HashtagFeedFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: HashtagQueryState, - since: Map?, - ): List = filterPostsByHashtags(key.hashtag, since) + since: SincePerRelayMap?, + ): List = filterPostsByHashtags(key.hashtag, key.relays, since) /** * Only one key per hashtag. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index 87319df30a..ada311d148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -21,11 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag class HashtagQueryState( val hashtag: String, + val relays: Set, ) { val lowercaseHashtag = hashtag.lowercase() } @@ -38,15 +40,11 @@ class HashtagFilterAssembler( HashtagFeedFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt index 665e1d69ad..1bf1a83872 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt @@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable fun HashtagFilterAssemblerSubscription( - tag: String, - dataSource: HashtagFilterAssembler, + tag: Route.Hashtag, + accountViewModel: AccountViewModel, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(tag) { - HashtagQueryState(tag) + HashtagQueryState(tag.hashtag, accountViewModel.account.followOutboxesOrProxy.flow.value) } - KeyDataSourceSubscription(state, dataSource) + KeyDataSourceSubscription(state, accountViewModel.dataSources().hashtags) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 5d3deaef4d..dab1e76605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -56,8 +57,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AROUND_ME -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.OnlineChecker.isOnline import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState @@ -72,9 +73,9 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton @@ -91,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import kotlin.collections.forEachIndexed @Composable fun HomeScreen( @@ -228,10 +230,13 @@ fun HomeScreenFloatingButton( accountViewModel: AccountViewModel, nav: INav, ) { - val list = accountViewModel.account.settings.defaultHomeFollowList.collectAsStateWithLifecycle() + val list = + accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() if (list.value == AROUND_ME) { - val location by Amethyst.instance.locationManager.geohashStateFlow.collectAsStateWithLifecycle() + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() when (val myLocation = location) { is LocationState.LocationResult.Success -> NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) @@ -340,10 +345,8 @@ fun DisplayLiveBubbles( val feed by liveFeed.feed.collectAsStateWithLifecycle() LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) { - itemsIndexed(feed.list, key = { _, item -> item.idHex }) { _, item -> - when (item) { - is EphemeralChatChannel -> RenderEphemeralBubble(item, accountViewModel, nav) - } + itemsIndexed(feed.list, key = { _, item -> item.roomId.toKey() }) { _, item -> + RenderEphemeralBubble(item, accountViewModel, nav) } } } @@ -375,17 +378,13 @@ fun CheckIfVideoIsOnline( accountViewModel: AccountViewModel, whenOnline: @Composable (Boolean) -> Unit, ) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url), - ) - } - - LaunchedEffect(key1 = url) { - accountViewModel.checkVideoIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } + val online by produceState( + initialValue = OnlineChecker.isOnlineCached(url), + key1 = url, + ) { + val isOnline = accountViewModel.checkVideoIsOnline(url) + if (value != isOnline) { + value = isOnline } } @@ -398,17 +397,13 @@ fun CrossfadeCheckIfVideoIsOnline( accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit, ) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url), - ) - } - - LaunchedEffect(key1 = url) { - accountViewModel.checkVideoIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } + val online by produceState( + initialValue = OnlineChecker.isOnlineCached(url), + key1 = url, + ) { + val isOnline = accountViewModel.checkVideoIsOnline(url) + if (value != isOnline) { + value = isOnline } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index fd522e3971..c61f41794a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -23,21 +23,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun HomeTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - FollowListWithRoutes( + FollowList( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, accountViewModel = accountViewModel, @@ -50,3 +54,21 @@ fun HomeTopBar( } } } + +@Composable +private fun FollowList( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index 6a6a4f59cc..cc87c14c6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -20,20 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable @@ -47,9 +45,9 @@ fun NewNoteButton(nav: INav) { containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - painter = painterRes(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 4), contentDescription = stringRes(R.string.new_post), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 36061b0362..ec309dcfd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,8 +24,6 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -45,36 +43,27 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -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.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.core.util.Consumer -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.PollIcon -import com.vitorpamplona.amethyst.ui.note.RegularPostIcon -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -100,9 +89,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import kotlinx.collections.immutable.persistentListOf @@ -174,80 +163,33 @@ private fun NewPostScreenInner( accountViewModel: AccountViewModel, nav: Nav, ) { - val scope = rememberCoroutineScope() - WatchAndLoadMyEmojiList(accountViewModel) Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { postViewModel.showRelaysDialog = true }, - ) { - Icon( - painter = painterRes(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - PostButton( - onPost = { - // uses the accountViewModel scope to avoid cancelling this - // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendPostSync() - delay(100) - nav.popBack() - } - }, - isActive = postViewModel.canPost(), - ) + PostingTopBar( + isActive = postViewModel::canPost, + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendPostSync() + delay(100) + nav.popBack() } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - // uses the accountViewModel scope to avoid cancelling this - // function when the postViewModel is released - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendDraftSync() - nav.popBack() - postViewModel.cancel() - } - }, - ) + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> - if (postViewModel.showRelaysDialog) { - RelaySelectionDialogEasy( - preSelectedList = postViewModel.relayList ?: persistentListOf(), - onClose = { postViewModel.showRelaysDialog = false }, - onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, - accountViewModel = accountViewModel, - nav = nav, - ) - } Surface( modifier = Modifier @@ -393,11 +335,11 @@ private fun NewPostScreenBody( postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( lud16, - accountViewModel.account.userProfile().pubkeyHex, + accountViewModel.account.userProfile(), accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.insertAtCursor(it) postViewModel.wantsInvoice = false }, @@ -420,7 +362,7 @@ private fun NewPostScreenBody( } } - if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { + if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) { Row( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), @@ -496,8 +438,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { } if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapraiser) { - postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + AddZapraiserButton(postViewModel.wantsZapRaiser) { + postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser } } @@ -530,9 +472,19 @@ private fun AddPollButton( onClick = { onClick() }, ) { if (!isPollActive) { - PollIcon() + Icon( + painter = painterRes(R.drawable.ic_poll, 1), + contentDescription = stringRes(id = R.string.poll), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) } else { - RegularPostIcon() + Icon( + painter = painterRes(R.drawable.ic_lists, 1), + contentDescription = stringRes(id = R.string.disable_poll), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 00f4996d58..5e1366a3b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -115,7 +116,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -136,6 +136,9 @@ open class ShortNotePostViewModel : IZapRaiser { val draftTag = DraftTagState() + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { @@ -147,18 +150,14 @@ open class ShortNotePostViewModel : } } - var accountViewModel: AccountViewModel? = null - var account: Account? = null - - var originalNote: Note? by mutableStateOf(null) - var forkedFromNote: Note? by mutableStateOf(null) + var originalNote: Note? by mutableStateOf(null) + var forkedFromNote: Note? by mutableStateOf(null) var pTags by mutableStateOf?>(null) var eTags by mutableStateOf?>(null) val iMetaAttachments = IMetaAttachments() - var nip95attachments by - mutableStateOf>>(emptyList()) + var nip95attachments by mutableStateOf>>(emptyList()) override var message by mutableStateOf(TextFieldValue("")) @@ -184,9 +183,8 @@ open class ShortNotePostViewModel : var consensusThreshold: Int? = null var closedAt: Long? = null - var isValidRecipients = mutableStateOf(true) - var isValidvalueMaximum = mutableStateOf(true) - var isValidvalueMinimum = mutableStateOf(true) + var isValidValueMaximum = mutableStateOf(true) + var isValidValueMinimum = mutableStateOf(true) var isValidConsensusThreshold = mutableStateOf(true) var isValidClosedAt = mutableStateOf(true) @@ -211,17 +209,14 @@ open class ShortNotePostViewModel : // ZapRaiser var canAddZapRaiser by mutableStateOf(false) - var wantsZapraiser by mutableStateOf(false) + var wantsZapRaiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) - var showRelaysDialog by mutableStateOf(false) - var relayList by mutableStateOf?>(null) + fun lnAddress(): String? = account.userProfile().info?.lnAddress() - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null - - fun user(): User? = account?.userProfile() + fun user(): User? = account.userProfile() open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -243,20 +238,17 @@ open class ShortNotePostViewModel : version: Note?, draft: Note?, ) { - val accountViewModel = accountViewModel ?: return val noteEvent = draft?.event val noteAuthor = draft?.author if (draft != null && noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } else { @@ -289,21 +281,23 @@ open class ShortNotePostViewModel : pTags = null } - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null + val user = account.userProfile() + + canAddInvoice = user.info?.lnAddress() != null + canAddZapRaiser = user.info?.lnAddress() != null canUsePoll = originalNote == null multiOrchestrator = null - quote?.let { - message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") + quote?.let { quotedNote -> + message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}") - it.author?.let { quotedUser -> - if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + quotedNote.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != user.pubkeyHex) { if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { forwardZapTo.value.addItem(quotedUser) } - if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { - forwardZapTo.value.addItem(accountViewModel.userProfile()) + if (forwardZapTo.value.items.none { it.key.pubkeyHex == user.pubkeyHex }) { + forwardZapTo.value.addItem(user) } val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } @@ -312,21 +306,21 @@ open class ShortNotePostViewModel : } } - fork?.let { - message = TextFieldValue(version?.event?.content ?: it.event?.content ?: "") + fork?.let { forkedNoted -> + message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "") - it.event?.isSensitiveOrNSFW()?.let { + forkedNoted.event?.isSensitiveOrNSFW()?.let { if (it) wantsToMarkAsSensitive = true } - it.event?.zapraiserAmount()?.let { + forkedNoted.event?.zapraiserAmount()?.let { zapRaiserAmount.value = it } - it.event?.zapSplitSetup()?.let { - val totalWeight = it.sumOf { if (it is ZapSplitSetupLnAddress) 0.0 else it.weight } + forkedNoted.event?.zapSplitSetup()?.let { setup -> + val totalWeight = setup.sumOf { if (it is ZapSplitSetupLnAddress) 0.0 else it.weight } - it.forEach { + setup.forEach { if (it is ZapSplitSetup) { forwardZapTo.value.addItem(LocalCache.getOrCreateUser(it.pubKeyHex), (it.weight / totalWeight).toFloat()) } @@ -335,7 +329,7 @@ open class ShortNotePostViewModel : // Only adds if it is not already set up. if (forwardZapTo.value.items.isEmpty()) { - it.author?.let { forkedAuthor -> + forkedNoted.author?.let { forkedAuthor -> if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { if (forwardZapTo.value.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.value.addItem(forkedAuthor) if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.value.addItem(accountViewModel.userProfile()) @@ -346,7 +340,7 @@ open class ShortNotePostViewModel : } } - it.author?.let { + forkedNoted.author?.let { if (this.pTags == null) { this.pTags = listOf(it) } else if (this.pTags?.contains(it) != true) { @@ -354,7 +348,7 @@ open class ShortNotePostViewModel : } } - forkedFromNote = it + forkedFromNote = forkedNoted } ?: run { forkedFromNote = null } @@ -375,19 +369,19 @@ open class ShortNotePostViewModel : } private fun loadFromDraft(draftEvent: TextNoteEvent) { - canAddInvoice = accountViewModel?.userProfile()?.info?.lnAddress() != null - canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null multiOrchestrator = null - val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } forwardZapTo.value = SplitBuilder() - localfowardZapTo.forEach { + localForwardZapTo.forEach { val user = LocalCache.getOrCreateUser(it[1]) val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } forwardZapToEditting.value = TextFieldValue("") - wantsForwardZapTo = localfowardZapTo.isNotEmpty() + wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -397,11 +391,11 @@ open class ShortNotePostViewModel : wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } - val zapraiser = draftEvent.zapraiserAmount() - wantsZapraiser = zapraiser != null + val zapRaiser = draftEvent.zapraiserAmount() + wantsZapRaiser = zapRaiser != null zapRaiserAmount.value = null - if (zapraiser != null) { - zapRaiserAmount.value = zapraiser + if (zapRaiser != null) { + zapRaiserAmount.value = zapRaiser } eTags = @@ -415,7 +409,7 @@ open class ShortNotePostViewModel : LocalCache.getOrCreateUser(it[1]) } - draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(3) == "fork" }.forEach { + draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { val note = LocalCache.checkGetOrCreateNote(it[1]) forkedFromNote = note } @@ -469,29 +463,27 @@ open class ShortNotePostViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val relayList = relayList + val extraNotesToBroadcast = mutableListOf() - if (nip95attachments.isNotEmpty() && relayList != null) { + if (nip95attachments.isNotEmpty()) { val usedImages = template.tags.taggedQuoteIds().toSet() nip95attachments.forEach { - if (usedImages.contains(it.second.id) == true) { - account?.sendNip95Privately(it.first, it.second, relayList) + if (usedImages.contains(it.second.id)) { + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) } } } - accountViewModel?.account?.signAndSendPrivatelyOrBroadcast( - template, - relayList = { relayList }, - ) + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) - accountViewModel?.deleteDraft(draftTag.current) + accountViewModel.deleteDraft(draftTag.current) cancel() } suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return + val accountViewModel = accountViewModel if (message.text.isBlank()) { accountViewModel.account.deleteDraft(draftTag.current) @@ -500,34 +492,28 @@ open class ShortNotePostViewModel : accountViewModel.account.createAndSendDraft(draftTag.current, template) nip95attachments.forEach { - account?.sendToPrivateOutboxAndLocal(it.first) - account?.sendToPrivateOutboxAndLocal(it.second) + account.sendToPrivateOutboxAndLocal(it.first) + account.sendToPrivateOutboxAndLocal(it.second) } } } private suspend fun createTemplate(): EventTemplate? { - if (accountViewModel == null) { - cancel() - return null - } - val tagger = NewMessageTagger( message.text, pTags, eTags, - originalNote?.channelHex(), - accountViewModel!!, + accountViewModel, ) tagger.run() val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() - val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null + val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -564,7 +550,7 @@ open class ShortNotePostViewModel : replyingTo = originalNote?.toEventHint(), forkingFrom = forkedFromNote?.toEventHint(), ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } + tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) } hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) @@ -598,51 +584,63 @@ open class ShortNotePostViewModel : server: ServerName, onError: (title: String, message: String) -> Unit, context: Context, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, ) { viewModelScope.launch(Dispatchers.Default) { - val myAccount = account ?: return@launch - val myMultiOrchestrator = multiOrchestrator ?: return@launch isUploadingImage = true val results = myMultiOrchestrator.upload( - viewModelScope, alt, contentWarningReason, MediaCompressor.intToCompressorQuality(mediaQuality), server, - myAccount, + account, context, ) if (results.allGood) { - results.successful.forEach { - if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) - } + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message) } - } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = - IMetaTagBuilder(it.result.url) + IMetaTagBuilder(state.result.url) .apply { - hash(it.result.fileHeader.hash) - size(it.result.fileHeader.size) - it.result.fileHeader.mimeType + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType ?.let { mimeType(it) } - it.result.fileHeader.dim + state.result.fileHeader.dim ?.let { dims(it) } - it.result.fileHeader.blurHash + state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } - it.result.magnet?.let { magnet(it) } - it.result.uploadedHash?.let { originalHash(it) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } contentWarningReason?.let { sensitiveContent(contentWarningReason) } @@ -650,7 +648,7 @@ open class ShortNotePostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(it.result.url) + message = message.insertUrlAtCursor(state.result.url) urlPreviews.update(message) } } @@ -658,7 +656,6 @@ open class ShortNotePostViewModel : multiOrchestrator = null } else { val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } @@ -676,7 +673,7 @@ open class ShortNotePostViewModel : pTags = null wantsPoll = false - zapRecipients = mutableStateListOf() + zapRecipients = mutableStateListOf() pollOptions = newStateMapPollOptions() valueMaximum = null valueMinimum = null @@ -684,7 +681,7 @@ open class ShortNotePostViewModel : closedAt = null wantsInvoice = false - wantsZapraiser = false + wantsZapRaiser = false zapRaiserAmount.value = null wantsForwardZapTo = false @@ -705,29 +702,9 @@ open class ShortNotePostViewModel : emojiSuggestions?.reset() - showRelaysDialog = false - - reloadRelaySet() - draftTag.rotate() } - fun reloadRelaySet() { - val account = accountViewModel?.account ?: return - - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } - } - fun deleteMediaToUpload(selected: SelectedMediaProcessing) { this.multiOrchestrator?.remove(selected) } @@ -740,8 +717,8 @@ open class ShortNotePostViewModel : updateMessage(TextFieldValue(message.text + " " + it)) } - override fun updateMessage(it: TextFieldValue) { - message = it + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage urlPreviews.update(message) if (message.selection.collapsed) { @@ -800,8 +777,11 @@ open class ShortNotePostViewModel : viewModelScope.launch(Dispatchers.IO) { iMetaAttachments.downloadAndPrepare( item.link.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, - ) + ) { + Amethyst.instance.okHttpClients.getHttpClient( + accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url), + ) + } } message = message.replaceCurrentWord(wordToInsert) @@ -818,13 +798,13 @@ open class ShortNotePostViewModel : message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && - (!wantsZapraiser || zapRaiserAmount.value != null) && + (!wantsZapRaiser || zapRaiserAmount.value != null) && ( !wantsPoll || ( pollOptions.values.all { it.isNotEmpty() } && - isValidvalueMinimum.value && - isValidvalueMaximum.value + isValidValueMinimum.value && + isValidValueMaximum.value ) ) && multiOrchestrator == null @@ -864,11 +844,11 @@ open class ShortNotePostViewModel : fun checkMinMax() { if ((valueMinimum ?: 0) > (valueMaximum ?: Long.MAX_VALUE)) { - isValidvalueMinimum.value = false - isValidvalueMaximum.value = false + isValidValueMinimum.value = false + isValidValueMaximum.value = false } else { - isValidvalueMinimum.value = true - isValidvalueMaximum.value = true + isValidValueMinimum.value = true + isValidValueMaximum.value = true } } @@ -882,7 +862,7 @@ open class ShortNotePostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index 4a1cedc051..0ff7d6fab8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -31,9 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class HomeConversationsFeedFilter( val account: Account, @@ -41,8 +42,8 @@ class HomeConversationsFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = - account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter || + account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter override fun feed(): List { val filterParams = buildFilterParams(account) @@ -58,10 +59,8 @@ class HomeConversationsFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) private fun innerApplyFilter(collection: Collection): Set { @@ -81,6 +80,7 @@ class HomeConversationsFeedFilter( event is PollNoteEvent || event is ChannelMessageEvent || event is CommentEvent || + event is VoiceReplyEvent || event is LiveActivitiesChatMessageEvent ) && filterParams.match(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 3806261a89..b7e819a57e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -21,72 +21,68 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.TimeUtils -import kotlin.collections.toSet class HomeLiveFilter( val account: Account, -) : AdditiveComplexFeedFilter() { +) : AdditiveComplexFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex override fun showHiddenKey(): Boolean = false fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) fun limitTime() = TimeUtils.fifteenMinutesAgo() - override fun feed(): List { - val gRelays = account.activeGlobalRelays().toSet() + override fun feed(): List { val filterParams = buildFilterParams(account) val fiveMinsAgo = limitTime() val list = - LocalCache.channels.filter { id, channel -> - shouldIncludeChannel(channel, gRelays, filterParams, fiveMinsAgo) + LocalCache.ephemeralChannels.filter { id, channel -> + shouldIncludeChannel(channel, filterParams, fiveMinsAgo) } return sort(list.toSet()) } fun shouldIncludeChannel( - channel: Channel, - gRelays: Set, + channel: EphemeralChatChannel, filterParams: FilterByListParams, timeLimit: Long, ): Boolean = - if (channel is EphemeralChatChannel) { - val list = - channel.notes.filter { key, value -> - acceptableEvent(value, gRelays, filterParams, timeLimit) - } - list.isNotEmpty() - } else { - false - } + channel.notes + .filter { key, value -> + acceptableEvent(value, filterParams, timeLimit) + }.isNotEmpty() override fun updateListWith( - oldList: List, + oldList: List, newItems: Set, - ): List { + ): List { val fiveMinsAgo = limitTime() val revisedOldList = oldList.filter { channel -> - channel.lastNoteCreatedAt > fiveMinsAgo + (channel.lastNote?.createdAt() ?: 0) > fiveMinsAgo } val newItemsToBeAdded = applyFilter(newItems) @@ -96,7 +92,7 @@ class HomeLiveFilter( .mapNotNull { val room = (it.event as? EphemeralChatEvent)?.roomId() if (room != null) { - LocalCache.getChannelIfExists(room) + LocalCache.getEphemeralChatChannelIfExists(room) } else { null } @@ -110,48 +106,56 @@ class HomeLiveFilter( } private fun applyFilter(collection: Collection): Set { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) return collection.filterTo(HashSet()) { - acceptableEvent(it, gRelays, filterParams, limitTime()) + acceptableEvent(it, filterParams, limitTime()) } } private fun acceptableEvent( - it: Note, - globalRelays: Set, + note: Note, filterParams: FilterByListParams, timeLimit: Long, ): Boolean { - val createdAt = it.createdAt() ?: return false - val noteEvent = it.event - val isGlobalRelay = it.relays.any { globalRelays.contains(it.url) } + val createdAt = note.createdAt() ?: return false + val noteEvent = note.event return (noteEvent is EphemeralChatEvent) && createdAt > timeLimit && - filterParams.match(noteEvent, isGlobalRelay) + filterParams.match(noteEvent, note.relays) } - fun sort(collection: Set): List { - val followingKeySet = - account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors + fun sort(collection: Set): List { + val topFilter = account.liveHomeFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors val followCounts = - collection.associate { it to followsThatParticipateOn(it, followingKeySet) } + collection.associateWith { followsThatParticipateOn(it, followingKeySet) } return collection.sortedWith( - compareByDescending { followCounts[it] } - .thenByDescending { it.lastNoteCreatedAt } - .thenBy { it.idHex }, + compareByDescending { followCounts[it] } + .thenByDescending { it.lastNote?.createdAt() ?: 0 } + .thenBy { it.roomId.id } + .thenBy { it.roomId.relayUrl }, ) } fun followsThatParticipateOn( - channel: Channel, + channel: EphemeralChatChannel, followingSet: Set?, ): Int { - if (channel == null) return 0 - var count = 0 channel.notes.forEach { key, value -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index ebd51792e9..a92e458716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -20,27 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal -import androidx.compose.ui.util.fastAny import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams 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.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent class HomeNewThreadFeedFilter( val account: Account, @@ -48,30 +49,27 @@ class HomeNewThreadFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = - account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter || + account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun feed(): List { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) val notes = LocalCache.notes.filterIntoSet { _, note -> // Avoids processing addressables twice. - (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, gRelays, filterParams) + (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams) } val longFormNotes = LocalCache.addressables.filterIntoSet { _, note -> - acceptableEvent(note, gRelays, filterParams) + acceptableEvent(note, filterParams) } return sort(notes + longFormNotes) @@ -80,21 +78,18 @@ class HomeNewThreadFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) private fun innerApplyFilter(collection: Collection): Set { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) return collection.filterTo(HashSet()) { - acceptableEvent(it, gRelays, filterParams) + acceptableEvent(it, filterParams) } } private fun acceptableEvent( it: Note, - globalRelays: Set, filterParams: FilterByListParams, ): Boolean { val noteEvent = it.event - val isGlobalRelay = it.relays.fastAny { globalRelays.contains(it.url) } return ( noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || @@ -104,12 +99,14 @@ class HomeNewThreadFeedFilter( (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || + (noteEvent is PublicMessageEvent && noteEvent.content.isNotEmpty() && noteEvent.isIncluded(account.signer.pubKey)) || noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || + noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && - filterParams.match(noteEvent, isGlobalRelay) && + filterParams.match(noteEvent, it.relays) && it.isNewThread() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index aab0c0375b..e3c7c339e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -22,15 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.mixGeohashHashtagsCommunities.MixGeohashHashtagsCommunityEoseManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxUsersEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag class HomeQueryState( val account: Account, + val feedState: AccountFeedContentStates, val scope: CoroutineScope, ) @@ -40,23 +40,19 @@ class HomeFilterAssembler( val group = listOf( HomeOutboxEventsEoseManager(client, ::allKeys), - HomeOutboxUsersEoseManager(client, ::allKeys), + // HomeOutboxUsersEoseManager(client, ::allKeys), // We can break it down, one for each sub if needed. // HashtagEventsFilterSubAssembler(client, ::allKeys), // GeohashEventsFilterSubAssembler(client, ::allKeys), // CommunityEventsFilterSubAssembler(client, ::allKeys), - MixGeohashHashtagsCommunityEoseManager(client, ::allKeys), + // MixGeohashHashtagsCommunityEoseManager(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt index c264bb8a03..34c15b5705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -43,7 +43,11 @@ fun HomeFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel.account) { - HomeQueryState(accountViewModel.account, accountViewModel.viewModelScope) + HomeQueryState( + accountViewModel.account, + accountViewModel.feedStates, + accountViewModel.viewModelScope, + ) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt deleted file mode 100644 index eb7f79e677..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.mixGeohashHashtagsCommunities - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Geohash.filterHomePostsByGeohashes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Hashtags.filterHomePostsByHashtags -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsFromCommunities -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class MixGeohashHashtagsCommunityEoseManager( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterHomePostsByHashtags(key.followLists()?.hashtags, since), - filterHomePostsByGeohashes(key.followLists()?.geotags, since), - filterHomePostsByScopes(key.followLists()?.hashtagScopes, since), - filterHomePostsByScopes(key.followLists()?.geotagScopes, since), - filterHomePostsFromCommunities(key.followLists()?.addresses, since), - ).flatten() - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt new file mode 100644 index 0000000000..a5e07906d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import kotlin.collections.flatten + +val HomePostsByGeohashKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterHomePostsByGeohashes( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsByGeohashKinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterHomePostsByGeohashes( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + + filterHomePostsByScopes( + relay = it.key, + scopesToLoad = it.value.geotagScopes, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt new file mode 100644 index 0000000000..a590028396 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterHomePostsByGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + newThreadSince: Long?, + repliesSince: Long?, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time + val relayUrl = it.key + listOf( + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsNewThreadKinds, + limit = 50, + since = since ?: newThreadSince, + ), + ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsConversationKinds, + limit = 50, + since = since ?: repliesSince, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt new file mode 100644 index 0000000000..8e9eddd2be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +val HomePostsBuHashtagsKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, + WikiNoteEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterHomePostsByHashtags( + relay: NormalizedRelayUrl, + hashToLoad: Set, + since: Long?, +): List { + if (hashToLoad.isEmpty()) return emptyList() + + val hashtags = hashToLoad.flatMap { hashtagAlts(it) }.distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsBuHashtagsKinds, + tags = mapOf("t" to hashtags), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByHashtags( + hashtagSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { + if (it.value.hashtags.isEmpty()) { + null + } else { + val since = since?.get(it.key)?.time ?: defaultSince + return filterHomePostsByHashtags( + relay = it.key, + hashToLoad = it.value.hashtags, + since = since, + ) + + filterHomePostsByScopes( + relay = it.key, + scopesToLoad = it.value.hashtagScopes, + since = since, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt deleted file mode 100644 index 7f575c0472..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Geohash - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -fun filterHomePostsByGeohashes( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null - - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - tags = mapOf("g" to hashToLoad.toList()), - limit = 100, - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt deleted file mode 100644 index 2bbff0f7b3..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Geohash - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlin.collections.flatten - -class GeohashEventsEoseManager( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterHomePostsByGeohashes(key.followLists()?.geotags, since), - filterHomePostsByScopes(key.followLists()?.geotagScopes, since), - ).flatten() - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt deleted file mode 100644 index ed83014e6e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Hashtags - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -fun filterHomePostsByHashtags( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null - - val hashtags = hashToLoad.map { hashtagAlts(it) }.flatten().distinct() - - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - CommentEvent.KIND, - WikiNoteEvent.KIND, - ), - tags = mapOf("t" to hashtags), - limit = 100, - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt deleted file mode 100644 index be9be75ba1..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Hashtags - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class HashtagEventsFilterSubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterHomePostsByHashtags( - key.followLists()?.hashtags, - since, - ), - filterHomePostsByScopes(key.followLists()?.hashtagScopes, since), - ).flatten() - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt similarity index 73% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt index 6a9c7b5823..04172ee0db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt @@ -20,24 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip22Comments.CommentEvent +val CommentKinds = listOf(CommentEvent.KIND) + fun filterHomePostsByScopes( - scopesToLoad: Set?, - since: Map?, -): List? { - if (scopesToLoad == null || scopesToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + scopesToLoad: Set, + since: Long?, +): List { + if (scopesToLoad.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(CommentEvent.KIND), + Filter( + kinds = CommentKinds, tags = mapOf("I" to scopesToLoad.toList()), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt new file mode 100644 index 0000000000..8c6a77b459 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsFromAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterHomePostsByAllFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + sinceBoundaryNew: Long?, + sinceBoundaryReply: Long?, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { (relay, filter) -> + val since = since?.get(relay)?.time + + listOfNotNull( + filter.authors?.let { + filterNewHomePostsByAuthors(relay, it, since ?: sinceBoundaryNew) + }, + filter.authors?.let { + filterReplyHomePostsByAuthors(relay, it, since ?: sinceBoundaryReply) + }, + filter.geotags?.let { + filterHomePostsByGeohashes(relay, it, since ?: sinceBoundaryNew) + }, + filter.geotagScopes?.let { + filterHomePostsByScopes(relay, it, since ?: sinceBoundaryNew) + }, + filter.hashtags?.let { + filterHomePostsByHashtags(relay, it, since ?: sinceBoundaryNew) + }, + filter.hashtagScopes?.let { + filterHomePostsByScopes(relay, it, since ?: sinceBoundaryNew) + }, + filter.communities?.let { + filterHomePostsFromAllCommunities(relay, it, since ?: sinceBoundaryNew) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt new file mode 100644 index 0000000000..b9de073882 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import kotlin.math.max + +val HomePostsNewThreadKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + PollNoteEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + ) + +val HomePostsConversationKinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + CommentEvent.KIND, + LiveActivitiesEvent.KIND, + EphemeralChatEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterNewHomePostsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsNewThreadKinds, + authors = authorList, + limit = max(authorList.size * 10, 300), + since = since, + ), + ), + ) +} + +fun filterReplyHomePostsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsConversationKinds, + authors = authorList, + limit = max(authorList.size * 10, 300), + since = since, + ), + ), + ) +} + +fun filterHomePostsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + sinceBoundaryNew: Long?, + sinceBoundaryReply: Long?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterNewHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryNew, + ) + + filterReplyHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryReply, + ) + } + }.flatten() +} + +fun filterHomePostsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + sinceBoundaryNew: Long?, + sinceBoundaryReply: Long?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterNewHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryNew, + ) + + filterReplyHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryReply, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByFollows.kt deleted file mode 100644 index 9ae9a876c4..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByFollows.kt +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -fun filterHomePostsByFollows( - follows: Map>?, - since: Map?, -): List { - if (follows != null && follows.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - EphemeralChatEvent.KIND, - HighlightEvent.KIND, - ), - authors = follows, - limit = 400, - since = since, - ), - ), - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - PollNoteEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = follows, - limit = 400, - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 08515e7dda..10803e6f10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -21,12 +21,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -37,11 +50,26 @@ import kotlinx.coroutines.launch class HomeOutboxEventsEoseManager( client: NostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: HomeQueryState, - since: Map?, - ): List? = filterHomePostsByFollows(key.followRelay(), since) + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + val newThreadSince = key.feedState.homeNewThreads.lastNoteCreatedAtIfFilled() + val repliesSince = key.feedState.homeReplies.lastNoteCreatedAtIfFilled() + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince) + is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince) + is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) + is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince) + is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince) + is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) + else -> emptyList() + } + } override fun user(key: HomeQueryState) = key.account.userProfile() @@ -51,9 +79,9 @@ class HomeOutboxEventsEoseManager( fun HomeQueryState.listName() = listNameFlow().value - fun HomeQueryState.followRelayFlow() = account.liveHomeListAuthorsPerRelay + fun HomeQueryState.followRelayFlow() = account.liveHomeFollowListsPerRelay - fun HomeQueryState.followRelay() = followRelayFlow().value + fun HomeQueryState.followsPerRelay() = followRelayFlow().value val userJobMap = mutableMapOf>() @@ -69,7 +97,17 @@ class HomeOutboxEventsEoseManager( } }, key.scope.launch(Dispatchers.Default) { - key.followRelayFlow().sample(5000).collectLatest { + key.followRelayFlow().sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { invalidateFilters() } }, @@ -82,7 +120,7 @@ class HomeOutboxEventsEoseManager( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt deleted file mode 100644 index 5d92c916c6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class CommunityEventsFilterSubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - filterHomePostsFromCommunities( - key.followLists()?.addresses, - since, - ) - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt new file mode 100644 index 0000000000..98fc5cc52f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterHomePostsFromAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to HomePostsFromCommunityKindsStr, + ), + limit = communityList.size * 20, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = HomePostsFromCommunityKinds, + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterHomePostsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterHomePostsFromAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt index 288d209666..a914febf50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -33,35 +34,83 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -fun filterHomePostsFromCommunities( - communitiesToLoad: Set?, - since: Map?, -): List? { - if (communitiesToLoad == null || communitiesToLoad.isEmpty()) return null +val HomePostsFromCommunityKinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + CommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + ) +val HomePostsFromCommunityKindsStr = + listOf( + TextNoteEvent.KIND.toString(), + LongTextNoteEvent.KIND.toString(), + ClassifiedsEvent.KIND.toString(), + HighlightEvent.KIND.toString(), + WikiNoteEvent.KIND.toString(), + CommunityPostApprovalEvent.KIND.toString(), + CommentEvent.KIND.toString(), + InteractiveStoryPrologueEvent.KIND.toString(), + ) + +fun filterHomePostsFromCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + // approved + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - CommentEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - ), + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( - "a" to communitiesToLoad.toList(), + "a" to listOf(community), + "k" to HomePostsFromCommunityKindsStr, ), limit = 100, since = since, ), ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = HomePostsFromCommunityKinds, + limit = 100, + since = since, + ), + ), ) } + +fun filterHomePostsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterHomePostsFromCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt index 7e1e37e4e0..e56e00ac3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt @@ -28,10 +28,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.model.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelNoteAuthors -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt index a4cf57fc0e..4d6e95a212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt @@ -503,7 +503,7 @@ private fun QrCodeButtonBase( }, ) { Icon( - painter = painterRes(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 4), contentDescription = stringRes(id = contentDescription), modifier = Modifier.size(24.dp), tint = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index bc8fd28747..59e3e78011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -72,10 +72,12 @@ class CardFeedContentState( val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: Any? = null override val isRefreshing: MutableState = mutableStateOf(false) + val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow(null) + fun sendToTop() { if (scrolltoTopPending) return @@ -90,6 +92,8 @@ class CardFeedContentState( private var lastAccount: Account? = null private var lastNotes: Set? = null + fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value + fun refresh() { viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } @@ -300,6 +304,13 @@ class CardFeedContentState( } private fun updateFeed(notes: ImmutableList) { + if (notes.size >= localFilter.limit()) { + val lastNomeTime = notes.lastOrNull()?.createdAt() + if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { + lastNoteCreatedAtWhenFullyLoaded.tryEmit(notes.lastOrNull()?.createdAt()) + } + } + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.tryEmit(CardFeedState.Empty) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 719dd6063c..45f673adf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index 6cf2d40c1a..2127ba8252 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -38,9 +38,9 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 2160678dcf..4724b827fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -23,26 +23,47 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithoutRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun NotificationTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultNotificationFollowList .collectAsStateWithLifecycle() - FollowListWithoutRoutes( + FollowLists( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, - accountViewModel, - ) { listName -> - accountViewModel.account.settings.changeDefaultNotificationFollowList(listName.code) - } + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList, + ) } } + +@Composable +private fun FollowLists( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 18ecdfd186..bb85db4548 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter @@ -28,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.forks.forkFromVersion import com.vitorpamplona.quartz.experimental.forks.isForkFromAddressWithPubkey +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent @@ -38,8 +40,9 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent @@ -63,10 +66,8 @@ class NotificationFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultNotificationFollowList.value, followLists = account.liveNotificationFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun feed(): List { @@ -74,8 +75,11 @@ class NotificationFeedFilter( val notifications = LocalCache.notes.filterIntoSet { _, note -> - acceptableEvent(note, filterParams) - } + note.event !is AddressableEvent && acceptableEvent(note, filterParams) + } + + LocalCache.addressables.filterIntoSet { _, note -> + acceptableEvent(note, filterParams) + } return sort(notifications) } @@ -100,7 +104,7 @@ class NotificationFeedFilter( val zapRequest = noteEvent.zapRequest if (zapRequest != null) { if (noteEvent.zapRequest?.isPrivateZap() == true) { - zapRequest.cachedPrivateZap()?.pubKey ?: zapRequest.pubKey + account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest)?.pubKey ?: zapRequest.pubKey } else { zapRequest.pubKey } @@ -108,7 +112,11 @@ class NotificationFeedFilter( noteEvent.pubKey } } else { - it.author?.pubkeyHex + if (it is AddressableNote) { + it.address.pubKeyHex + } else { + it.author?.pubkeyHex + } } return it.event !is ChannelCreateEvent && @@ -120,8 +128,9 @@ class NotificationFeedFilter( it.event !is NIP90StatusEvent && it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is GiftWrapEvent && + it.event !is PrivateTagArrayEvent && (it.event is LnZapEvent || notifAuthor != loggedInUserHex) && - (filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) && + (filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && it.event?.isTaggedUser(loggedInUserHex) ?: false && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && tagsAnEventByUser(it, loggedInUserHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt new file mode 100644 index 0000000000..f8279cc1f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -0,0 +1,379 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.MessageFieldRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.SendDirectMessageTo +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewPublicMessageScreen( + to: Set? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: NewPublicMessageViewModel = viewModel() + postViewModel.init(accountViewModel) + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + to?.let { + postViewModel.load(it) + } + } + } + + WatchAndLoadMyEmojiList(accountViewModel) + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.public_message, + isActive = postViewModel::canPost, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendDraftSync() + delay(100) + nav.popBack() + postViewModel.cancel() + } + }, + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendPostSync() + nav.popBack() + postViewModel.cancel() + } + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + PublicMessageScreenContent(postViewModel, accountViewModel, nav) + } + } +} + +@Composable +fun PublicMessageScreenContent( + postViewModel: NewPublicMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scrollState = rememberScrollState() + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(horizontal = Size10dp).weight(1f)) { + Column( + Modifier.fillMaxWidth().verticalScroll(scrollState), + verticalArrangement = spacedBy(Size10dp), + ) { + SendDirectMessageTo(postViewModel, accountViewModel) + + MessageFieldRow(postViewModel, accountViewModel) + + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) + + if (postViewModel.wantsToMarkAsSensitive) { + ContentSensitivityExplainer() + } + + if (postViewModel.wantsToAddGeoHash) { + LocationAsHash(postViewModel) + } + + if (postViewModel.wantsForwardZapTo) { + ForwardZapTo(postViewModel, accountViewModel) + } + + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + ImageVideoDescription( + it, + accountViewModel.account.settings.defaultFileServer, + onAdd = { alt, server, sensitiveContent, mediaQuality -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + NewPostInvoiceRequest( + onSuccess = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + accountViewModel, + ) + } + + if (postViewModel.wantsSecretEmoji) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + Modifier.heightIn(0.dp, 300.dp), + ) + } + + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + accountViewModel, + Modifier.heightIn(0.dp, 300.dp), + ) + } + + BottomRowActions(postViewModel, accountViewModel) + } +} + +@Composable +private fun BottomRowActions( + postViewModel: NewPublicMessageViewModel, + accountViewModel: AccountViewModel, +) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.toggleMarkAsSensitive() + } + + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} + +@Composable +fun SendDirectMessageTo( + postViewModel: NewPublicMessageViewModel, + accountViewModel: AccountViewModel, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + launch { + delay(200) + focusRequester.requestFocus() + } + } + + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringRes(R.string.messages_new_message_to), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + ThinPaddingTextField( + value = postViewModel.toUsers, + onValueChange = postViewModel::updateToUsers, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringRes(R.string.messages_new_message_to_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + HorizontalDivider(thickness = DividerThickness) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt new file mode 100644 index 0000000000..f42093791c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -0,0 +1,644 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +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.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.collections.plus + +@Stable +class NewPublicMessageViewModel : + ViewModel(), + ILocationGrabber, + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + // don't save the first + if (it > 0) { + sendDraftSync() + } + } + } + } + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + override var message by mutableStateOf(TextFieldValue("")) + + val urlPreviews = PreviewState() + + var isUploadingImage by mutableStateOf(false) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + var toUsers by mutableStateOf(TextFieldValue("")) + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + var wantsSecretEmoji by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + override val forwardZapTo = mutableStateOf>(SplitBuilder()) + override val forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + override var zapRaiserAmount = mutableStateOf(null) + + fun lnAddress(): String? = account.userProfile().info?.lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null + + fun user(): User? = account.userProfile() + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + } + + fun load(users: Set) { + val userSet = users - account.userProfile().pubkeyHex + + toUsers = + TextFieldValue( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + } + + fun quote(quote: Note) { + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + urlPreviews.update(message) + + // creates a split with that author. + val quotedAuthor = quote.author ?: return + + if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedAuthor.pubkeyHex }) { + forwardZapTo.value.addItem(quotedAuthor) + } + if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.value.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedAuthor.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.9f) + + wantsForwardZapTo = true + } + } + + fun editFromDraft(draft: Note) { + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event as? PublicMessageEvent ?: return + + val localForwardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localForwardZapTo.sumOf { it.weight } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + if (it is ZapSplitSetup) { + val user = LocalCache.getOrCreateUser(it.pubKeyHex) + forwardZapTo.value.addItem(user, (it.weight / totalWeight).toFloat()) + } + // don't support editing old-style splits. + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount.value = null + if (zapraiser != null) { + zapRaiserAmount.value = zapraiser + } + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex + + toUsers = + TextFieldValue( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + + message = TextFieldValue(draftEvent.content) + urlPreviews.update(message) + + iMetaAttachments.addAll(draftEvent.imetas()) + } + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + val extraNotesToBroadcast = mutableListOf() + + if (nip95attachments.isNotEmpty()) { + val usedImages = template.tags.taggedQuoteIds().toSet() + nip95attachments.forEach { + if (usedImages.contains(it.second.id)) { + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) + } + } + } + + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + accountViewModel.deleteDraft(draftTag.current) + + cancel() + } + + suspend fun sendDraftSync() { + val accountViewModel = accountViewModel + + if (message.text.isBlank()) { + accountViewModel.account.deleteDraft(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag.current, template) + + nip95attachments.forEach { + account.sendToPrivateOutboxAndLocal(it.first) + account.sendToPrivateOutboxAndLocal(it.second) + } + } + } + + private suspend fun createTemplate(): EventTemplate? { + val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel) + toUsersTagger.run() + + val tagger = NewMessageTagger(message.text, null, null, accountViewModel) + tagger.run() + + val users = (toUsersTagger.pTags ?: emptyList()) + (tagger.pTags ?: emptyList()) + val toUsers = users.mapTo(mutableSetOf()) { ReceiverTag(it.pubkeyHex, it.bestRelayHint()) } + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null + + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + + return PublicMessageEvent.build( + to = toUsers.toList(), + msg = tagger.message, + ) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + alt, + contentWarningReason, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + account, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message) + } + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + }.build() + + iMetaAttachments.replace(iMeta.url, iMeta) + + message = message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message) + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + fun cancel() { + toUsers = TextFieldValue("") + message = TextFieldValue("") + multiOrchestrator = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount.value = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsSecretEmoji = false + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + urlPreviews.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + iMetaAttachments.reset() + + emojiSuggestions?.reset() + + draftTag.rotate() + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + fun addToMessage(it: String) { + updateMessage(TextFieldValue(message.text + " " + it)) + } + + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + urlPreviews.update(newMessage) + + if (message.selection.collapsed) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + + val lastWord = message.currentWord() + userSuggestions?.processCurrentWord(lastWord) + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + fun updateToUsers(newToUsersValue: TextFieldValue) { + toUsers = newToUsersValue + + if (newToUsersValue.selection.collapsed) { + val lastWord = newToUsersValue.currentWord() + userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS + userSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting.value = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { + val lastWord = toUsers.currentWord() + toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + val wordToInsert = ":${item.code}:" + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url)) + } + } + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount.value != null) && + (toUsers.text.isNotBlank()) && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + + draftTag.newVersion() + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index cae2914e39..87179f8f4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -20,34 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel @@ -68,41 +56,15 @@ fun PrivacyOptionsScreen( Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Text( - stringRes(id = R.string.privacy_options), - ) - SaveButton( - onPost = { - accountViewModel.setTorSettings(dialogViewModel.save()) - nav.popBack() - }, - isActive = true, - ) - } + SavingTopBar( + titleRes = R.string.privacy_options, + onCancel = { + nav.popBack() }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - nav.popBack() - }, - ) - } + onPost = { + accountViewModel.setTorSettings(dialogViewModel.save()) + nav.popBack() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index c064f5c02d..f62ec2ac3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.BookmarkTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.TabBookmarks diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt index 63163d4b22..73f09154d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal.UserProfileBookmarksFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt index 9b4584d436..dee62a42af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt @@ -22,12 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses -import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark class UserProfileBookmarksFeedFilter( val user: User, @@ -36,22 +37,19 @@ class UserProfileBookmarksFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex override fun feed(): List { + val note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + val noteEvent = note.event as? BookmarkListEvent + + if (noteEvent == null) return emptyList() + val notes = - user.latestBookmarkList - ?.taggedEvents() - ?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } - ?.toSet() - ?: emptySet() + noteEvent.publicBookmarks().mapNotNull { + when (it) { + is AddressBookmark -> LocalCache.getOrCreateAddressableNote(it.address) + is EventBookmark -> LocalCache.checkGetOrCreateNote(it.eventId) + } + } - val addresses = - user.latestBookmarkList - ?.taggedAddresses() - ?.map { LocalCache.getOrCreateAddressableNote(it) } - ?.toSet() - ?: emptySet() - - return (notes + addresses) - .filter { account.isAcceptable(it) } - .sortedWith(DefaultFeedOrder) + return notes.reversed() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt index 9ffe26481b..e5c89e3c03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal.UserProfileConversationsFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index ddef713b37..8cfa16a104 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class UserProfileConversationsFeedFilter( val user: User, @@ -65,6 +66,7 @@ class UserProfileConversationsFeedFilter( it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent || it.event is CommentEvent || + it.event is VoiceReplyEvent || it.event is TorrentCommentEvent ) && !it.isNewThread() && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt index ae2ae24266..274fc334bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt @@ -20,28 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -fun filterUserProfileFollowers( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() +val UserProfileFollowersKinds = listOf(ContactListEvent.KIND) - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, +fun filterUserProfileFollowers( + user: User, + since: SincePerRelayMap?, +): List = + user.inboxRelays().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf(key)), - since = since, + Filter( + kinds = UserProfileFollowersKinds, + tags = mapOf("p" to listOf(user.pubkeyHex)), + since = since?.get(it)?.time, ), - ), - ) -} + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 84ea68fcb0..948c3fa135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -20,38 +20,38 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -fun filterUserProfileLists( - keys: Set, - since: Map?, -): List { - if (keys.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - BookmarkListEvent.KIND, - PeopleListEvent.KIND, - FollowListEvent.KIND, - AppRecommendationEvent.KIND, - ), - authors = keys.toList(), - limit = 100, - since = since, - ), - ), +val UserProfileListKinds = + listOf( + BookmarkListEvent.KIND, + PeopleListEvent.KIND, + FollowListEvent.KIND, + HashtagListEvent.KIND, + AppRecommendationEvent.KIND, ) -} + +fun filterUserProfileLists( + users: Map>, + since: SincePerRelayMap?, +): List = + users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = UserProfileListKinds, + authors = it.value.sorted(), + limit = 100, + since = since?.get(it.key)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index f0ac100d13..a1a97d3320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -20,38 +20,36 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -fun filterUserProfileMedia( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - PictureEvent.KIND, - ProfileGalleryEntryEvent.KIND, - VideoVerticalEvent.KIND, - VideoHorizontalEvent.KIND, - ), - authors = listOf(key), - limit = 200, - since = since, - ), - ), +val UserProfileMediaKinds = + listOf( + PictureEvent.KIND, + ProfileGalleryEntryEvent.KIND, + VideoVerticalEvent.KIND, + VideoHorizontalEvent.KIND, ) -} + +fun filterUserProfileMedia( + user: User, + since: SincePerRelayMap?, +): List = + user.outboxRelays().map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfileMediaKinds, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt index 58564b764f..696dba77b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt @@ -20,37 +20,38 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterUserProfileMetadata( - keys: Set, - since: Map?, -): List { - if (keys.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ContactListEvent.KIND, - BadgeProfilesEvent.KIND, - ), - authors = keys.toList(), - since = since, - ), - ), +val UserProfileMetadataKinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + ContactListEvent.KIND, + BadgeProfilesEvent.KIND, ) + +fun filterUserProfileMetadata( + users: Map>, + since: SincePerRelayMap?, +): List { + if (users.isEmpty()) return emptyList() + + return users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = UserProfileMetadataKinds, + authors = it.value.sorted(), + since = since?.get(it.key)?.time, + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 030e4bd562..05f1534b0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -20,13 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -37,49 +36,58 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +val UserProfilePostKinds1 = + listOf( + TextNoteEvent.KIND, + GenericRepostEvent.KIND, + RepostEvent.KIND, + LongTextNoteEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + VoiceEvent.KIND, + ) + +val UserProfilePostKinds2 = + listOf( + TorrentEvent.KIND, + TorrentCommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, + VoiceReplyEvent.KIND, + ) fun filterUserProfilePosts( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - GenericRepostEvent.KIND, - RepostEvent.KIND, - LongTextNoteEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, + user: User, + since: SincePerRelayMap?, +): List = + user + .outboxRelays() + .map { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds1, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, ), - authors = listOf(key), - limit = 200, - since = since, ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TorrentEvent.KIND, - TorrentCommentEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - CommentEvent.KIND, + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds2, + authors = listOf(user.pubkeyHex), + limit = 50, + since = since?.get(relay)?.time, ), - authors = listOf(key), - limit = 50, - since = since, ), - ), - ) -} + ) + }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index d5c91849f2..ffd9635425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -20,29 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -fun filterUserProfileZapsReceived( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, +fun filterUserProfileZapsReceived( + user: User, + since: SincePerRelayMap?, +): List = + user.inboxRelays().map { relay -> + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(LnZapEvent.KIND), - tags = mapOf("p" to listOf(key)), + Filter( + kinds = UserProfileZapReceiverKinds, + tags = mapOf("p" to listOf(user.pubkeyHex)), limit = 200, - since = since, + since = since?.get(relay)?.time, ), - ), - ) -} + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt index 41737c9830..f2a5a1d031 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class UserProfileQueryState( @@ -42,10 +42,6 @@ class UserProfileFilterAssembler( UserProfileZapsFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt index 609c7c4271..dbfd219712 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfileFollowersFilterSubAssembler( client: NostrClient, @@ -31,11 +31,8 @@ class UserProfileFollowersFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterUserProfileFollowers(user(key).pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List? = filterUserProfileFollowers(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt index 4c5086d01e..40fadd1a4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.flatten +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfileMediaFilterSubAssembler( client: NostrClient, @@ -32,11 +31,8 @@ class UserProfileMediaFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterUserProfileMedia(user(key).pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List = filterUserProfileMedia(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 6d6d71cef7..0b028e7410 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.flatten +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.mapOfSet class UserProfileMetadataFilterSubAssembler( client: NostrClient, @@ -32,15 +32,20 @@ class UserProfileMetadataFilterSubAssembler( ) : SingleSubEoseManager(client, allKeys) { override fun updateFilter( keys: List, - since: Map?, - ): List? { - val keys = keys.mapTo(mutableSetOf()) { key -> key.user.pubkeyHex } - - // TODO: Load outbox for each user. + since: SincePerRelayMap?, + ): List? { + val userPerRelay = + mapOfSet { + keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user -> + user.outboxRelays().forEach { relay -> + add(relay, user.pubkeyHex) + } + } + } return listOfNotNull( - filterUserProfileMetadata(keys, since), - filterUserProfileLists(keys, since), + filterUserProfileMetadata(userPerRelay, since), + filterUserProfileLists(userPerRelay, since), ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt index 1d85fb9fa9..a4223f5a69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.flatten +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfilePostsFilterSubAssembler( client: NostrClient, @@ -32,11 +31,8 @@ class UserProfilePostsFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterUserProfilePosts(user(key).pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List = filterUserProfilePosts(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt index 37b6763bac..04808d7247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.flatten +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfileZapsFilterSubAssembler( client: NostrClient, @@ -32,10 +31,10 @@ class UserProfileZapsFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = listOfNotNull( - filterUserProfileZapsReceived(user(key).pubkeyHex, since), + filterUserProfileZapsReceived(user(key), since), ).flatten() override fun user(key: UserProfileQueryState) = key.user diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt index 4349f3f10e..d242b96256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt @@ -28,7 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowers -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt index 0e42b45a14..aaa112059d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt @@ -28,7 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt index c02a5be3a9..aeb22a742e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -154,7 +154,7 @@ fun ClickableNote( } else { baseNote } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav.nav(it) } + routeFor(redirectToNote, accountViewModel.account)?.let { nav.nav(it) } }, onLongClick = showPopup, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index ff2b851003..6c68988524 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.note.elements.BannerImage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index adddcb61c9..c4f8729cbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt index d32bf9456f..778e99afdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt @@ -64,7 +64,7 @@ fun DeleteFromGalleryDialog( buttonIcon = Icons.Default.Delete, buttonText = stringRes(R.string.quick_action_delete_dialog_btn), onClickDoOnce = { - accountViewModel.removefromMediaGallery(note) + accountViewModel.removeFromMediaGallery(note) onDismiss() }, onDismiss = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt index 0e57acbe95..c1f49d05ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal.UserProfileGalleryFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 61eaf4f3bc..2866e57668 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -78,10 +78,8 @@ class UserProfileGalleryFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt index af082b3f0e..4c46aa32bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollows +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollowCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -34,7 +34,7 @@ fun FollowedTagsTabHeader( baseUser: User, accountViewModel: AccountViewModel, ) { - val usertags by observeUserTagFollows(baseUser, accountViewModel) + val usertags by observeUserTagFollowCount(baseUser, accountViewModel) Text(text = "$usertags ${stringRes(R.string.followed_tags)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt index d6bdc150d4..718d2b814e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt @@ -22,17 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollows +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagHeader import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -40,31 +42,26 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness @Composable fun TabFollowedTags( baseUser: User, - account: AccountViewModel, + accountViewModel: AccountViewModel, nav: INav, ) { - val items = - remember(baseUser) { - baseUser.latestContactList?.unverifiedFollowTagSet() - } - Column( Modifier .fillMaxHeight() .padding(vertical = 0.dp), ) { - items?.let { - LazyColumn { - itemsIndexed(items) { index, hashtag -> - HashtagHeader( - tag = hashtag, - account = account, - onClick = { nav.nav(Route.Hashtag(hashtag)) }, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } + val items by observeUserTagFollows(baseUser, accountViewModel) + + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed(items) { index, hashtag -> + HashtagHeader( + tag = hashtag, + account = accountViewModel, + onClick = { nav.nav(Route.Hashtag(hashtag)) }, + ) + HorizontalDivider( + thickness = DividerThickness, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index e7731decf4..776bdc8c97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -26,17 +26,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequestCard @@ -51,12 +51,11 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse @Composable fun DisplayLNAddress( lud16: String?, - userHex: String, + user: User, accountViewModel: AccountViewModel, nav: INav, ) { val context = LocalContext.current - val scope = rememberCoroutineScope() var zapExpanded by remember { mutableStateOf(false) } var showErrorMessageDialog by remember { mutableStateOf(null) } @@ -67,7 +66,7 @@ fun DisplayLNAddress( textContent = showErrorMessageDialog ?: "", onClickStartMessage = { nav.nav { - routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel) + routeToMessage(user, showErrorMessageDialog, accountViewModel = accountViewModel) } }, onDismiss = { showErrorMessageDialog = null }, @@ -105,13 +104,13 @@ fun DisplayLNAddress( ) { InvoiceRequestCard( lud16, - userHex, + user, accountViewModel, onSuccess = { zapExpanded = false // pay directly - if (accountViewModel.account.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor(it, null, onSent = {}) { response -> + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor(it, null) { response -> if (response is PayInvoiceSuccessResponse) { showInfoMessageDialog = stringRes(context, R.string.payment_successful) } else if (response is PayInvoiceErrorResponse) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 3ae75e8f42..59fb554333 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -146,7 +146,7 @@ fun DrawAdditionalInfo( onClick = { dialogOpen = true }, ) { Icon( - painter = painterRes(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 1), contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, @@ -186,9 +186,19 @@ fun DrawAdditionalInfo( } } - val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } - val pubkeyHex = remember { baseUser.pubkeyHex } - DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + val lud16 = + remember(userState) { + userState + ?.user + ?.info + ?.lud16 + ?.trim() ?: userState + ?.user + ?.info + ?.lud06 + ?.trim() + } + DisplayLNAddress(lud16, baseUser, accountViewModel, nav) val identities = user.latestMetadata?.identityClaims() if (!identities.isNullOrEmpty()) { @@ -196,7 +206,7 @@ fun DrawAdditionalInfo( Row(verticalAlignment = Alignment.CenterVertically) { Icon( tint = Color.Unspecified, - painter = painterRes(id = getIdentityClaimIcon(identity)), + painter = painterRes(resourceId = getIdentityClaimIcon(identity), getIdentityClaimIconReference(identity)), contentDescription = stringRes(getIdentityClaimDescription(identity)), modifier = Modifier.size(16.dp), ) @@ -253,3 +263,12 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is GitHubIdentity -> R.string.github else -> R.drawable.github } + +fun getIdentityClaimIconReference(identity: IdentityClaimTag): Int = + when (identity) { + is TwitterIdentity -> 0 + is TelegramIdentity -> 0 + is MastodonIdentity -> 0 + is GitHubIdentity -> 0 + else -> 0 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index c516c8aeae..fd87e10452 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -70,7 +70,7 @@ fun DrawBanner( model = banner, contentDescription = stringRes(id = R.string.profile_image), contentScale = ContentScale.FillWidth, - placeholder = painterRes(R.drawable.profile_banner), + placeholder = painterRes(R.drawable.profile_banner, 1), modifier = Modifier .fillMaxWidth() @@ -90,7 +90,7 @@ fun DrawBanner( } } else { Image( - painter = painterRes(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 2), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt index c4db3cba31..9a1a41d06a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ZeroPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt index b2b1f36b7d..16bf12de6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt @@ -24,21 +24,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextFieldDefaults.contentPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @Composable fun MessageButton( @@ -46,20 +45,23 @@ fun MessageButton( accountViewModel: AccountViewModel, nav: INav, ) { - val scope = rememberCoroutineScope() - FilledTonalButton( modifier = Modifier .padding(horizontal = 3.dp) .width(50.dp), onClick = { - scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav.nav(Route.Room(it)) } } + nav.nav { + routeToMessage( + room = ChatroomKey(setOf(user.pubkeyHex)), + account = accountViewModel.account, + ) + } }, contentPadding = ZeroPadding, ) { Icon( - painter = painterRes(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 1), stringRes(R.string.send_a_direct_message), modifier = Size20Modifier, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index 1601f63e3e..92f7c6d737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index cc2a1809b8..0ef030fde9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt index 30d0abb84e..fc6b2359b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt @@ -36,7 +36,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 3830032eda..2ead89f33f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -35,8 +35,8 @@ import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 4c94c02e7f..0311015e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -20,25 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges +import android.R.attr.onClick import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote @@ -46,23 +39,24 @@ import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache 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.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun DisplayBadges( @@ -114,17 +108,20 @@ private fun LoadAndRenderBadge( accountViewModel: AccountViewModel, nav: INav, ) { - var baseNote by remember(badgeAwardEvent) { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEvent)) } - - LaunchedEffect(key1 = badgeAwardEvent) { - if (baseNote == null) { - withContext(Dispatchers.IO) { - baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEvent) + val baseNote = + produceState( + LocalCache.getNoteIfExists(badgeAwardEvent), + badgeAwardEvent, + ) { + val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent) + if (newValue != value) { + value = newValue } } - } - baseNote?.let { ObserveAndRenderBadge(it, accountViewModel, nav) } + baseNote.value?.let { + ObserveAndRenderBadge(it, accountViewModel, nav) + } } @Composable @@ -133,90 +130,80 @@ private fun ObserveAndRenderBadge( accountViewModel: AccountViewModel, nav: INav, ) { - val badgeAwardState by observeNote(it, accountViewModel) - val baseBadgeDefinition = badgeAwardState.note.replyTo?.firstOrNull() - baseBadgeDefinition?.let { BadgeThumb(it, accountViewModel, nav, Size35dp) } -} - -@Composable -fun BadgeThumb( - note: Note, - accountViewModel: AccountViewModel, - nav: INav, - size: Dp, - pictureModifier: Modifier = Modifier, -) { - BadgeThumb(note, accountViewModel, size, pictureModifier) { nav.nav(Route.Note(note.idHex)) } + val badgeAwardState by observeNoteEvent(it, accountViewModel) + val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull() + if (badgeDefinitionId != null) { + LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote -> + badgeDefNote?.let { + BadgeThumb(it, accountViewModel, nav) + } + } + } } @Composable fun BadgeThumb( baseNote: Note, accountViewModel: AccountViewModel, - size: Dp, - pictureModifier: Modifier = Modifier, - onClick: ((String) -> Unit)? = null, + nav: INav, ) { Box( - remember { - Modifier - .width(size) - .height(size) - }, + modifier = + Size35Modifier.clickable( + onClick = { + nav.nav { + routeFor(baseNote, accountViewModel.account) + } + }, + ), ) { - WatchAndRenderBadgeImage(baseNote, size, pictureModifier, accountViewModel, onClick) + WatchAndRenderBadgeImage(baseNote, accountViewModel) } } @Composable private fun WatchAndRenderBadgeImage( baseNote: Note, - size: Dp, - pictureModifier: Modifier, accountViewModel: AccountViewModel, - onClick: ((String) -> Unit)?, ) { - val noteState by observeNote(baseNote, accountViewModel) - val eventId = remember(noteState) { noteState?.note?.idHex } ?: return - val image by - remember(noteState) { - derivedStateOf { - val event = noteState.note.event as? BadgeDefinitionEvent + val event by observeNoteEvent(baseNote, accountViewModel) + + event?.let { + val image = + remember(event) { event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } } + RenderBadgeImage(it.id, it.name(), image, accountViewModel) + } +} + +@Composable +private fun RenderBadgeImage( + id: String, + name: String?, + image: String?, + accountViewModel: AccountViewModel, +) { + val description = + if (name != null) { + stringRes(id = R.string.badge_award_image_for, name) + } else { + stringRes(id = R.string.badge_award_image) } if (image == null) { RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringRes(R.string.unknown_author), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - }, + robot = "badgenotfound", + contentDescription = description, + modifier = BadgePictureModifier, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) } else { RobohashFallbackAsyncImage( - robot = eventId, - model = image!!, - contentDescription = stringRes(id = R.string.profile_image), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - .clip(shape = CutCornerShape(20)) - .run { - if (onClick != null) { - this.clickable(onClick = { onClick(eventId) }) - } else { - this - } - } - }, + robot = id, + model = image, + contentDescription = description, + modifier = BadgePictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt index 92f1f085cb..9eef64867c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index d4897240a7..f799c97503 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 8e79370d70..0678a0564f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent class UserProfileNewThreadFeedFilter( val user: User, @@ -79,6 +80,7 @@ class UserProfileNewThreadFeedFilter( it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || it.event is AudioHeaderEvent || + it.event is VoiceEvent || it.event is TorrentEvent ) && it.isNewThread() && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 1d78d29dc8..af1206d858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -26,10 +26,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -51,21 +54,32 @@ fun RelayFeedView( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(feedState, key = { _, item -> item.url }) { _, item -> - RelayCompose( - item, - accountViewModel = accountViewModel, - onAddRelay = { - nav.nav(Route.EditRelays(item.url)) - }, - onRemoveRelay = { - nav.nav(Route.EditRelays(item.url)) - }, - ) - HorizontalDivider( - thickness = DividerThickness, - ) + itemsIndexed(feedState, key = { _, item -> item.url.url }) { _, item -> + RenderRelayRow(item, accountViewModel, nav) } } } } + +@Composable +private fun RenderRelayRow( + relay: RelayInfo, + accountViewModel: AccountViewModel, + nav: INav, +) { + val clipboardManager = LocalClipboardManager.current + RelayCompose( + relay, + accountViewModel = accountViewModel, + onAddRelay = { + clipboardManager.setText(AnnotatedString(relay.url.url)) + nav.nav(Route.EditRelays) + }, + onRemoveRelay = { + nav.nav(Route.EditRelays) + }, + ) + HorizontalDivider( + thickness = DividerThickness, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index 000db6ae45..7c8333adc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ReadWrite -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -49,7 +49,7 @@ class RelayFeedViewModel : val order = compareByDescending { it.lastEvent } .thenByDescending { it.counter } - .thenBy { it.url } + .thenBy { it.url.url } private val _feedContent = MutableStateFlow>(emptyList()) val feedContent = _feedContent.asStateFlow() @@ -79,14 +79,14 @@ class RelayFeedViewModel : } fun mergeRelays( - relaysBeingUsed: Map, - relays: Map?, + relaysBeingUsed: Map, + relays: Map?, ): List { val userRelaysBeingUsed = relaysBeingUsed.map { it.value } val currentUserRelays = relays?.mapNotNull { - val url = RelayUrlFormatter.normalize(it.key) + val url = it.key if (url !in relaysBeingUsed) { RelayInfo(url, 0, 0) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt index e55e66e2b1..2a2a4258af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt @@ -30,7 +30,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -64,8 +64,6 @@ fun TabRelays( lifeCycleOwner.lifecycle.addObserver(observer) onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) - println("Profile Relay Dispose") - feedViewModel.unsubscribeTo(user) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt index e487ff0112..8701d7e946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt index 9807d23654..acd98d7cb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt index 39a4943ab8..b7769fb46a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -85,26 +84,23 @@ open class LnZapFeedViewModel( } } - var collectorJob: Job? = null - init { Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + invalidateData() } + } + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + invalidateData() + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt index 6fe532ab11..e51280ff2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt index 708c7f10d1..3505d8b848 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt @@ -28,16 +28,20 @@ import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.coroutines.CancellationException @Composable -fun NIP19QrCodeScanner(onScan: (Route?) -> Unit) { +fun NIP19QrCodeScanner( + accountViewModel: AccountViewModel, + onScan: (Route?) -> Unit, +) { SimpleQrCodeScanner { try { - onScan(uriToRoute(it)) + onScan(uriToRoute(it, accountViewModel.account)) } catch (e: Throwable) { if (e is CancellationException) throw e Log.e("NIP19 Scanner", "Error parsing $it", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index 0392dc9ad0..98d79371ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayNIP05 import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -220,7 +220,7 @@ fun ShowQRDialog( } } } else { - NIP19QrCodeScanner { + NIP19QrCodeScanner(accountViewModel) { if (it == null) { presenting = true } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt index 9cb0b02b17..bb6e918c15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt @@ -29,33 +29,20 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -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.nip59Giftwrap.seals.SealedRumorEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable @@ -66,24 +53,15 @@ fun LoadRedirectScreen( ) { if (eventId == null) return - var noteBase by remember { mutableStateOf(null) } - - LaunchedEffect(eventId) { - launch(Dispatchers.IO) { - val newNoteBase = LocalCache.checkGetOrCreateNote(eventId) - if (newNoteBase != noteBase) { - noteBase = newNoteBase - } + LoadNote(eventId, accountViewModel) { note -> + note?.let { + LoadRedirectScreen( + baseNote = note, + accountViewModel = accountViewModel, + nav = nav, + ) } } - - noteBase?.let { - LoadRedirectScreen( - baseNote = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } } @Composable @@ -95,11 +73,13 @@ fun LoadRedirectScreen( val noteState by observeNote(baseNote, accountViewModel) LaunchedEffect(key1 = noteState) { - val note = noteState?.note ?: return@LaunchedEffect - val event = note.event - + val event = noteState.note.event if (event != null) { - withContext(Dispatchers.IO) { redirect(event, accountViewModel, nav) } + withContext(Dispatchers.IO) { + routeFor(event, accountViewModel.account)?.let { route -> + nav.popUpTo(route, Route.EventRedirect::class) + } + } } } @@ -111,62 +91,3 @@ fun LoadRedirectScreen( Text(stringRes(R.string.looking_for_event, baseNote.idHex)) } } - -fun redirect( - eventId: HexKey, - accountViewModel: AccountViewModel, - nav: INav, -) { - LocalCache.getNoteIfExists(eventId)?.event?.let { - redirect(it, accountViewModel, nav) - } -} - -fun redirect( - event: Event, - accountViewModel: AccountViewModel, - nav: INav, -) { - val channelHex = - if ( - event is ChannelMessageEvent || - event is ChannelMetadataEvent || - event is ChannelCreateEvent || - event is LiveActivitiesChatMessageEvent || - event is LiveActivitiesEvent - ) { - (event as? ChannelMessageEvent)?.channelId() - ?: (event as? ChannelMetadataEvent)?.channelId() - ?: (event as? ChannelCreateEvent)?.id - ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.aTag()?.toTag() - } else { - null - } - - if (event is GiftWrapEvent) { - event.innerEventId?.let { - redirect(it, accountViewModel, nav) - } ?: run { - accountViewModel.unwrap(event) { redirect(it, accountViewModel, nav) } - } - } else if (event is SealedRumorEvent) { - event.innerEventId?.let { - redirect(it, accountViewModel, nav) - } ?: run { - accountViewModel.unseal(event) { redirect(it, accountViewModel, nav) } - } - } else { - if (event is ChannelCreateEvent) { - nav.popUpTo(Route.Channel(event.id), Route.EventRedirect::class) - } else if (event is ChatroomKeyable) { - val withKey = event.chatroomKey(accountViewModel.userProfile().pubkeyHex) - accountViewModel.userProfile().createChatroom(withKey) - nav.popUpTo(Route.Room(withKey.hashCode()), Route.EventRedirect::class) - } else if (channelHex != null) { - nav.popUpTo(Route.Channel(channelHex), Route.EventRedirect::class) - } else { - nav.popUpTo(Route.Note(event.id), Route.EventRedirect::class) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 8783514197..0f86454ea4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -20,13 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays -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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -35,31 +32,31 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults 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.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.Kind3RelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.renderKind3Items -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.renderKind3ProposalItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.renderLocalItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.PrivateOutboxRelayListViewModel @@ -67,117 +64,139 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.renderPrivateO import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65HomeItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65NotifItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy.ProxyRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy.renderProxyItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.renderSearchItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.TrustedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.renderTrustedItems import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer import com.vitorpamplona.amethyst.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.ammolite.relays.Constants @Composable fun AllRelayListScreen( - relayToAdd: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { - MappedAllRelayListView(relayToAdd ?: "", accountViewModel, nav) + val dmViewModel: DMRelayListViewModel = viewModel() + val nip65ViewModel: Nip65RelayListViewModel = viewModel() + val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel() + val searchViewModel: SearchRelayListViewModel = viewModel() + val blockedViewModel: BlockedRelayListViewModel = viewModel() + val trustedViewModel: TrustedRelayListViewModel = viewModel() + val localViewModel: LocalRelayListViewModel = viewModel() + val connectedViewModel: ConnectedRelayListViewModel = viewModel() + val broadcastViewModel: BroadcastRelayListViewModel = viewModel() + val indexerViewModel: IndexerRelayListViewModel = viewModel() + val proxyViewModel: ProxyRelayListViewModel = viewModel() + + dmViewModel.init(accountViewModel) + nip65ViewModel.init(accountViewModel) + searchViewModel.init(accountViewModel) + localViewModel.init(accountViewModel) + privateOutboxViewModel.init(accountViewModel) + connectedViewModel.init(accountViewModel) + blockedViewModel.init(accountViewModel) + trustedViewModel.init(accountViewModel) + broadcastViewModel.init(accountViewModel) + indexerViewModel.init(accountViewModel) + proxyViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel) { + dmViewModel.load() + nip65ViewModel.load() + searchViewModel.load() + localViewModel.load() + privateOutboxViewModel.load() + connectedViewModel.load() + blockedViewModel.load() + trustedViewModel.load() + broadcastViewModel.load() + indexerViewModel.load() + proxyViewModel.load() + } + + MappedAllRelayListView( + dmViewModel, + nip65ViewModel, + searchViewModel, + localViewModel, + privateOutboxViewModel, + connectedViewModel, + blockedViewModel, + trustedViewModel, + broadcastViewModel, + indexerViewModel, + proxyViewModel, + accountViewModel, + nav, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MappedAllRelayListView( - relayToAdd: String = "", + dmViewModel: DMRelayListViewModel, + nip65ViewModel: Nip65RelayListViewModel, + searchViewModel: SearchRelayListViewModel, + localViewModel: LocalRelayListViewModel, + privateOutboxViewModel: PrivateOutboxRelayListViewModel, + connectedViewModel: ConnectedRelayListViewModel, + blockedViewModel: BlockedRelayListViewModel, + trustedViewModel: TrustedRelayListViewModel, + broadcastViewModel: BroadcastRelayListViewModel, + indexerViewModel: IndexerRelayListViewModel, + proxyViewModel: ProxyRelayListViewModel, accountViewModel: AccountViewModel, newNav: INav, ) { - val kind3ViewModel: Kind3RelayListViewModel = viewModel() - val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle() - val kind3Proposals by kind3ViewModel.proposedRelays.collectAsStateWithLifecycle() - - val dmViewModel: DMRelayListViewModel = viewModel() val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle() - - val nip65ViewModel: Nip65RelayListViewModel = viewModel() val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle() val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle() - - val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel() val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle() - - val searchViewModel: SearchRelayListViewModel = viewModel() val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle() - - val localViewModel: LocalRelayListViewModel = viewModel() + val blockedFeedState by blockedViewModel.relays.collectAsStateWithLifecycle() + val trustedFeedState by trustedViewModel.relays.collectAsStateWithLifecycle() val localFeedState by localViewModel.relays.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - kind3ViewModel.load(accountViewModel.account) - dmViewModel.load(accountViewModel.account) - nip65ViewModel.load(accountViewModel.account) - searchViewModel.load(accountViewModel.account) - localViewModel.load(accountViewModel.account) - privateOutboxViewModel.load(accountViewModel.account) - } + val connectedRelays by connectedViewModel.relays.collectAsStateWithLifecycle() + val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle() + val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle() + val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.relay_settings), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SaveButton( - onPost = { - kind3ViewModel.create() - dmViewModel.create() - nip65ViewModel.create() - searchViewModel.create() - localViewModel.create() - privateOutboxViewModel.create() - newNav.popBack() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.relay_settings, + onCancel = { + dmViewModel.clear() + nip65ViewModel.clear() + searchViewModel.clear() + localViewModel.clear() + privateOutboxViewModel.clear() + trustedViewModel.clear() + blockedViewModel.clear() + broadcastViewModel.clear() + indexerViewModel.clear() + proxyViewModel.clear() + newNav.popBack() }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - kind3ViewModel.clear() - dmViewModel.clear() - nip65ViewModel.clear() - searchViewModel.clear() - localViewModel.clear() - privateOutboxViewModel.clear() - newNav.popBack() - }, - ) - } + onPost = { + dmViewModel.create() + nip65ViewModel.create() + searchViewModel.create() + localViewModel.create() + privateOutboxViewModel.create() + trustedViewModel.create() + blockedViewModel.create() + broadcastViewModel.create() + indexerViewModel.create() + proxyViewModel.create() + newNav.popBack() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> @@ -233,6 +252,35 @@ fun MappedAllRelayListView( } renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav) + item { + SettingsCategory( + stringRes(R.string.proxy_section), + stringRes(R.string.proxy_section_explainer), + SettingsCategorySpacingModifier, + ) + } + renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.broadcast_section), + stringRes(R.string.broadcast_section_explainer), + SettingsCategorySpacingModifier, + ) + } + renderBroadcastItems(broadcastRelays, broadcastViewModel, accountViewModel, newNav) + + item { + SettingsCategoryWithButton( + stringRes(R.string.indexer_section), + stringRes(R.string.indexer_section_explainer), + SettingsCategorySpacingModifier, + ) { + ResetIndexerRelays(indexerViewModel) + } + } + renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, newNav) + item { SettingsCategoryWithButton( stringRes(R.string.search_section), @@ -254,43 +302,35 @@ fun MappedAllRelayListView( renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav) item { - SettingsCategoryWithButton( - stringRes(R.string.kind_3_section), - stringRes(R.string.kind_3_section_description), + SettingsCategory( + stringRes(R.string.trusted_section), + stringRes(R.string.trusted_section_explainer), SettingsCategorySpacingModifier, - ) { - ResetKind3Relays(kind3ViewModel) - } + ) } - renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd) + renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav) - if (kind3Proposals.isNotEmpty()) { - item { - SettingsCategory( - stringRes(R.string.kind_3_recommended_section), - stringRes(R.string.kind_3_recommended_section_description), - SettingsCategorySpacingModifier, - ) - } - renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav) + item { + SettingsCategory( + stringRes(R.string.blocked_section), + stringRes(R.string.blocked_section_explainer), + SettingsCategorySpacingModifier, + ) } + renderBlockedItems(blockedFeedState, blockedViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.connected_section), + stringRes(R.string.connected_section_description), + SettingsCategorySpacingModifier, + ) + } + renderConnectedItems(connectedRelays, connectedViewModel, accountViewModel, newNav) } } } -@Composable -fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) { - OutlinedButton( - onClick = { - postViewModel.deleteAll() - postViewModel.addAll(Constants.defaultRelays) - postViewModel.loadRelayDocuments() - }, - ) { - Text(stringRes(R.string.default_relays)) - } -} - @Composable fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) { OutlinedButton( @@ -308,6 +348,23 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) { } } +@Composable +fun ResetIndexerRelays(postViewModel: IndexerRelayListViewModel) { + OutlinedButton( + onClick = { + postViewModel.deleteAll() + DefaultIndexerRelayList.forEach { + postViewModel.addRelay( + relaySetupInfoBuilder(it), + ) + } + postViewModel.loadRelayDocuments() + }, + ) { + Text(stringRes(R.string.default_relays)) + } +} + @Composable fun ResetDMRelays(postViewModel: DMRelayListViewModel) { OutlinedButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt deleted file mode 100644 index 8e71bd5f73..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt +++ /dev/null @@ -1,438 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.ClickableEmail -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.timeAgo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayDebugMessage -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import kotlinx.collections.immutable.toImmutableList - -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) -@Composable -fun RelayInformationDialog( - onClose: () -> Unit, - relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - relayInfo: Nip11RelayInformation, - accountViewModel: AccountViewModel, - nav: INav, -) { - val newNav = rememberExtendedNav(nav, onClose) - - val messages = - remember(relayBriefInfo) { - RelayStats - .get(url = relayBriefInfo.url) - .messages - .snapshot() - .values - .sortedByDescending { it.time } - .toImmutableList() - } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - ), - ) { - SetDialogToEdgeToEdge() - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - actions = {}, - title = { - Text(relayBriefInfo.displayUrl) - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - BackButton( - onPress = { onClose() }, - ) - } - }, - ) - }, - ) { pad -> - LazyColumn( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .padding(bottom = Size10dp, start = Size10dp, end = Size10dp) - .fillMaxSize(), - ) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = StdPadding.fillMaxWidth(), - ) { - Column { - RenderRelayIcon( - displayUrl = relayBriefInfo.displayUrl, - iconUrl = relayInfo.icon ?: relayBriefInfo.favIcon, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - RelayStats.get(url = relayBriefInfo.url).pingInMs, - iconModifier = LargeRelayIconModifier, - ) - } - - Spacer(modifier = DoubleHorzSpacer) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Title(relayInfo.name?.trim() ?: "") - Spacer(modifier = HalfVertSpacer) - SubtitleContent(relayBriefInfo.url) - } - } - } - item { - Section(stringRes(R.string.description)) - - SectionContent(relayInfo.description?.trim() ?: "") - } - item { - Section(stringRes(R.string.owner)) - - relayInfo.pubkey?.let { - DisplayOwnerInformation(it, accountViewModel, newNav) - } - } - item { - Section(stringRes(R.string.contact)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - relayInfo.contact?.let { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) - } - } - } - } - item { - Section(stringRes(R.string.software)) - - DisplaySoftwareInformation(relayInfo) - - Section(stringRes(R.string.version)) - - SectionContent(relayInfo.version ?: "") - } - item { - Section(stringRes(R.string.supports)) - - DisplaySupportedNips(relayInfo) - } - item { - relayInfo.fees?.admission?.let { - if (it.isNotEmpty()) { - Section(stringRes(R.string.admission_fees)) - - it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } - } - } - - relayInfo.payments_url?.let { - Section(stringRes(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it, - ) - } - } - } - item { - relayInfo.limitation?.let { - Section(stringRes(R.string.limitations)) - val authRequiredText = - if (it.auth_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val paymentRequiredText = - if (it.payment_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val restrictedWritesText = - if (it.restricted_writes ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - Column { - SectionContent( - "${stringRes(R.string.message_length)}: ${it.max_message_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", - ) - SectionContent("${stringRes(R.string.filters)}: ${it.max_filters ?: 0}") - SectionContent( - "${stringRes(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") - SectionContent( - "${stringRes(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", - ) - SectionContent( - "${stringRes(R.string.content_length)}: ${it.max_content_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.max_limit)}: ${it.max_limit ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") - SectionContent("${stringRes(R.string.auth)}: $authRequiredText") - SectionContent("${stringRes(R.string.payment)}: $paymentRequiredText") - SectionContent("${stringRes(R.string.restricted_writes)}: $restrictedWritesText") - } - } - } - item { - relayInfo.relay_countries?.let { - Section(stringRes(R.string.countries)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.language_tags?.let { - Section(stringRes(R.string.languages)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.tags?.let { - Section(stringRes(R.string.tags)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.posting_policy?.let { - Section(stringRes(R.string.posting_policy)) - - Box(Modifier.padding(10.dp)) { - ClickableUrl( - it, - it, - ) - } - } - } - - item { - Section(stringRes(R.string.relay_error_messages)) - } - - items(messages) { msg -> - Row { - RenderDebugMessage(msg, accountViewModel, newNav) - } - - Spacer(modifier = StdVertSpacer) - } - } - } - } -} - -@Composable -private fun RenderDebugMessage( - msg: RelayDebugMessage, - accountViewModel: AccountViewModel, - newNav: INav, -) { - SelectionContainer { - val context = LocalContext.current - val color = - remember { - mutableStateOf(Color.Transparent) - } - TranslatableRichTextViewer( - content = - remember { - "${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}" - }, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = color, - id = msg.hashCode().toString(), - accountViewModel = accountViewModel, - nav = newNav, - ) - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) { - FlowRow { - relayInfo.supported_nips?.forEach { item -> - val text = item.toString().padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) - } - } - - relayInfo.supported_nip_extensions?.forEach { item -> - val text = item.padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) - } - } - } -} - -@Composable -private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) { - val url = (relayInfo.software ?: "").replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) - } -} - -@Composable -private fun DisplayOwnerInformation( - userHex: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadUser(baseUserHex = userHex, accountViewModel) { - CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } -} - -@Composable -fun Title(text: String) { - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - ) -} - -@Composable -fun SubtitleContent(text: String) { - Text( - text = text, - ) -} - -@Composable -fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) -} - -@Composable -fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt new file mode 100644 index 0000000000..708e6c4db5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -0,0 +1,450 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.ClickableEmail +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RelayInformationScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { + RelayInformationScreen( + relay = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RelayInformationScreen( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + actions = {}, + title = { + Text(relay.displayUrl()) + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + BackButton( + onPress = nav::popBack, + ) + } + }, + ) + }, + ) { pad -> + val relayInfo by loadRelayInfo(relay, accountViewModel) + + val messages = + remember(relay) { + RelayStats + .get(url = relay) + .messages + .snapshot() + .values + .sortedByDescending { it.time } + .toImmutableList() + } + + LazyColumn( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .padding(bottom = Size10dp, start = Size10dp, end = Size10dp) + .fillMaxSize(), + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = StdPadding.fillMaxWidth(), + ) { + Column { + RenderRelayIcon( + displayUrl = relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + RelayStats.get(relay).pingInMs, + iconModifier = LargeRelayIconModifier, + ) + } + + Spacer(modifier = DoubleHorzSpacer) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Title(relayInfo.name?.trim() ?: "") + Spacer(modifier = HalfVertSpacer) + SubtitleContent(relay.url) + } + } + } + item { + Section(stringRes(R.string.description)) + + SectionContent(relayInfo.description?.trim() ?: stringRes(R.string.no_description)) + } + relayInfo.pubkey?.let { + item { + Section(stringRes(R.string.owner)) + DisplayOwnerInformation(it, accountViewModel, nav) + } + } + relayInfo.contact?.let { + item { + Section(stringRes(R.string.contact)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + if (it.startsWith("https:")) { + ClickableUrl(urlText = it, url = it) + } else if (it.startsWith("mailto:") || it.contains('@')) { + ClickableEmail(it) + } else { + SectionContent(it) + } + } + } + } + relayInfo.software?.let { + item { + Section(stringRes(R.string.software)) + + DisplaySoftwareInformation(it) + + Section(stringRes(R.string.version)) + + SectionContent(relayInfo.version ?: "") + } + } + relayInfo.supported_nips?.let { + if (it.isNotEmpty()) { + item { + Section(stringRes(R.string.supports)) + + DisplaySupportedNips(it, relayInfo.supported_nip_extensions) + } + } + } + relayInfo.fees?.admission?.let { + item { + if (it.isNotEmpty()) { + Section(stringRes(R.string.admission_fees)) + + it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } + } + } + } + + relayInfo.payments_url?.let { + item { + Section(stringRes(R.string.payments_url)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = it, + url = it, + ) + } + } + } + + relayInfo.limitation?.let { + item { + Section(stringRes(R.string.limitations)) + val authRequiredText = + if (it.auth_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) + + val paymentRequiredText = + if (it.payment_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) + + val restrictedWritesText = + if (it.restricted_writes ?: false) stringRes(R.string.yes) else stringRes(R.string.no) + + Column { + SectionContent( + "${stringRes(R.string.message_length)}: ${it.max_message_length ?: 0}", + ) + SectionContent( + "${stringRes(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", + ) + SectionContent("${stringRes(R.string.filters)}: ${it.max_filters ?: 0}") + SectionContent( + "${stringRes(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", + ) + SectionContent("${stringRes(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") + SectionContent( + "${stringRes(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", + ) + SectionContent( + "${stringRes(R.string.content_length)}: ${it.max_content_length ?: 0}", + ) + SectionContent( + "${stringRes(R.string.max_limit)}: ${it.max_limit ?: 0}", + ) + SectionContent("${stringRes(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") + SectionContent("${stringRes(R.string.auth)}: $authRequiredText") + SectionContent("${stringRes(R.string.payment)}: $paymentRequiredText") + SectionContent("${stringRes(R.string.restricted_writes)}: $restrictedWritesText") + } + } + } + relayInfo.relay_countries?.let { + item { + Section(stringRes(R.string.countries)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + } + relayInfo.language_tags?.let { + item { + Section(stringRes(R.string.languages)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + } + relayInfo.tags?.let { + item { + Section(stringRes(R.string.tags)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + } + relayInfo.posting_policy?.let { + item { + Section(stringRes(R.string.posting_policy)) + + Box(Modifier.padding(10.dp)) { + ClickableUrl( + it, + it, + ) + } + } + } + + item { + Section(stringRes(R.string.relay_error_messages)) + } + + items(messages) { msg -> + Row { + RenderDebugMessage(msg, accountViewModel, nav) + } + + Spacer(modifier = StdVertSpacer) + } + } + } +} + +@Composable +private fun RenderDebugMessage( + msg: RelayDebugMessage, + accountViewModel: AccountViewModel, + newNav: INav, +) { + SelectionContainer { + val context = LocalContext.current + val color = + remember { + mutableStateOf(Color.Transparent) + } + TranslatableRichTextViewer( + content = + remember { + "${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}" + }, + canPreview = false, + quotesLeft = 0, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = color, + id = msg.hashCode().toString(), + accountViewModel = accountViewModel, + nav = newNav, + ) + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun DisplaySupportedNips( + supportedNips: List, + supportedNipExtensions: List?, +) { + FlowRow { + supportedNips.forEach { item -> + val text = item.toString().padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } + + supportedNipExtensions?.forEach { item -> + val text = item.padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } + } +} + +@Composable +private fun DisplaySoftwareInformation(software: String) { + val url = software.replace("git+", "") + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = url, + url = url, + ) + } +} + +@Composable +private fun DisplayOwnerInformation( + userHex: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadUser(baseUserHex = userHex, accountViewModel) { + CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun Title(text: String) { + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) +} + +@Composable +fun SubtitleContent(text: String) { + Text( + text = text, + ) +} + +@Composable +fun Section(text: String) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = DoubleVertSpacer) +} + +@Composable +fun SectionContent(text: String) { + Text( + modifier = Modifier.padding(start = 10.dp), + text = text, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt new file mode 100644 index 0000000000..3e3c1eaf58 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun BlockedRelayList( + postViewModel: BlockedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderBlockedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderBlockedItems( + feedState: List, + postViewModel: BlockedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Trusted" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt new file mode 100644 index 0000000000..77f93513b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.blockedRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveBlockedRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt new file mode 100644 index 0000000000..5fd691e581 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun BroadcastRelayList( + postViewModel: BroadcastRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderBroadcastItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderBroadcastItems( + feedState: List, + postViewModel: BroadcastRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Broadcast" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt new file mode 100644 index 0000000000..fb36e2080e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.stream.Collectors.toList + +class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.broadcastRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveBroadcastRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt index e9ec4f33f9..3fac4efd4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt @@ -21,24 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Immutable data class BasicRelaySetupInfo( - val url: String, + val relay: NormalizedRelayUrl, val relayStat: RelayStat, val paidRelay: Boolean = false, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) -} + val forcesTor: Boolean = false, +) -fun relaySetupInfoBuilder(url: String): BasicRelaySetupInfo { - val normalized = RelayUrlFormatter.normalize(url) - return BasicRelaySetupInfo( - normalized, - RelayStats.get(normalized), +fun relaySetupInfoBuilder( + normalized: NormalizedRelayUrl, + forcesTor: Boolean = false, +): BasicRelaySetupInfo = + BasicRelaySetupInfo( + relay = normalized, + relayStat = RelayStats.get(normalized), + forcesTor = forcesTor, ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 910d0dd101..98da2d9219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalFoundationApi::class) @Composable @@ -49,7 +50,7 @@ fun BasicRelaySetupInfoClickableRow( item: BasicRelaySetupInfo, loadProfilePicture: Boolean, loadRobohash: Boolean, - onDelete: (BasicRelaySetupInfo) -> Unit, + onDelete: ((BasicRelaySetupInfo) -> Unit)?, onClick: () -> Unit, accountViewModel: AccountViewModel, ) { @@ -60,7 +61,7 @@ fun BasicRelaySetupInfoClickableRow( .combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) + clipboardManager.setText(AnnotatedString(item.relay.url)) }, ), ) { @@ -68,11 +69,11 @@ fun BasicRelaySetupInfoClickableRow( verticalAlignment = Alignment.CenterVertically, modifier = HalfVertPadding, ) { - val iconUrlFromRelayInfoDoc by loadRelayInfo(item.url, accountViewModel) + val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay, accountViewModel) RenderRelayIcon( - item.briefInfo.displayUrl, - iconUrlFromRelayInfoDoc?.icon ?: item.briefInfo.favIcon, + iconUrlFromRelayInfoDoc.id ?: item.relay.displayUrl(), + iconUrlFromRelayInfoDoc.icon, loadProfilePicture, loadRobohash, item.relayStat.pingInMs, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 9739139789..e5b2b7b4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -22,39 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo @Composable fun BasicRelaySetupInfoDialog( item: BasicRelaySetupInfo, - onDelete: (BasicRelaySetupInfo) -> Unit, + onDelete: ((BasicRelaySetupInfo) -> Unit)?, accountViewModel: AccountViewModel, nav: INav, ) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } + val relayInfo by loadRelayInfo(item.relay, accountViewModel) BasicRelaySetupInfoClickableRow( item = item, @@ -62,54 +43,6 @@ fun BasicRelaySetupInfoDialog( loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, onDelete = onDelete, accountViewModel = accountViewModel, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 067beb38d4..9f84c96d5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch abstract class BasicRelaySetupInfoModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel lateinit var account: Account private val _relays = MutableStateFlow>(emptyList()) @@ -40,20 +43,24 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { var hasModified = false - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { clear() loadRelayDocuments() } - abstract fun getRelayList(): List? + abstract fun getRelayList(): List? - abstract fun saveRelayList(urlList: List) + abstract suspend fun saveRelayList(urlList: List) fun create() { if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - saveRelayList(_relays.value.map { it.url }) + accountViewModel.runIOCatching { + saveRelayList(_relays.value.map { it.relay }) clear() } } @@ -63,8 +70,10 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _relays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { + Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) + }, onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -75,20 +84,25 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { } fun clear() { - var hasModified = false _relays.update { val relayList = getRelayList() ?: emptyList() relayList - .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .map { + relaySetupInfoBuilder( + normalized = it, + forcesTor = + account.torRelayState.flow.value + .useTor(it), + ) + }.distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } } fun addRelay(relay: BasicRelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return + if (relays.value.any { it.relay == relay.relay }) return _relays.update { it.plus(relay) } hasModified = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt index 6915ce6b41..a19a212359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt @@ -40,28 +40,30 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalFoundationApi::class) @Composable fun RelayNameAndRemoveButton( item: BasicRelaySetupInfo, onClick: () -> Unit, - onDelete: (BasicRelaySetupInfo) -> Unit, + onDelete: ((BasicRelaySetupInfo) -> Unit)?, modifier: Modifier, ) { val clipboardManager = LocalClipboardManager.current Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Text( - text = item.briefInfo.displayUrl, + text = item.relay.displayUrl(), modifier = Modifier.combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) + clipboardManager.setText(AnnotatedString(item.relay.url)) }, ), maxLines = 1, @@ -71,29 +73,43 @@ fun RelayNameAndRemoveButton( if (item.paidRelay) { Icon( imageVector = Icons.Default.Paid, - null, + contentDescription = stringRes(id = R.string.paid_relay), modifier = Modifier - .padding(start = 5.dp, top = 1.dp) + .padding(start = 5.dp) + .size(14.dp), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + } + + if (item.forcesTor) { + Icon( + painter = painterRes(R.drawable.ic_tor, 2), + contentDescription = stringRes(id = R.string.tor_relay), + modifier = + Modifier + .padding(start = 5.dp) .size(14.dp), tint = MaterialTheme.colorScheme.allGoodColor, ) } } - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = - Modifier - .padding(start = 10.dp) - .size(15.dp), - tint = WarningColor, - ) + if (onDelete != null) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = { onDelete(item) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringRes(id = R.string.remove), + modifier = + Modifier + .padding(start = 10.dp) + .size(15.dp), + tint = LightRedColor, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index f21bc2e095..9eadc8c941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Preview @Composable @@ -57,7 +59,7 @@ fun RelayUrlEditFieldPreview() { } @Composable -fun RelayUrlEditField(onNewRelay: (String) -> Unit) { +fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { var url by remember { mutableStateOf("") } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { @@ -85,8 +87,11 @@ fun RelayUrlEditField(onNewRelay: (String) -> Unit) { KeyboardActions( onGo = { if (url.isNotBlank() && url != "/") { - onNewRelay(url) - url = "" + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + } } }, ), @@ -95,8 +100,11 @@ fun RelayUrlEditField(onNewRelay: (String) -> Unit) { Button( onClick = { if (url.isNotBlank() && url != "/") { - onNewRelay(url) - url = "" + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + } } }, shape = ButtonBorder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt new file mode 100644 index 0000000000..5b27d5890d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun ConnectedRelayList( + postViewModel: ConnectedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val newNav = rememberExtendedNav(nav, onClose) + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderConnectedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderConnectedItems( + feedState: List, + postViewModel: ConnectedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Connected" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = null, + accountViewModel = accountViewModel, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt new file mode 100644 index 0000000000..9759533876 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List = + account.client + .relayStatusFlow() + .value.available + .sorted() + + override suspend fun saveRelayList(urlList: List) { + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt index 0915c4d974..3f8df123f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt @@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,11 +30,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -45,13 +40,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier @@ -64,7 +57,11 @@ fun AddDMRelayListDialog( ) { val postViewModel: DMRelayListViewModel = viewModel() - LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel.account) { + postViewModel.load() + } Dialog( onDismissRequest = onClose, @@ -73,39 +70,16 @@ fun AddDMRelayListDialog( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Text(stringRes(R.string.dm_relays_title)) - - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.dm_relays_title, + onCancel = { + postViewModel.clear() + onClose() }, - navigationIcon = { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) + onPost = { + postViewModel.create() + onClose() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 6abd8e54be..79e66ae290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -64,7 +64,7 @@ fun LazyListScope.renderDMItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "DM" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index dc47689185..01b31c931a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class DMRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getDMRelayList()?.relays() + override fun getRelayList(): List? = account.dmRelayList.getDMRelayList()?.relays() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.saveDMRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt new file mode 100644 index 0000000000..59c2671274 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun IndexerRelayList( + postViewModel: IndexerRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderIndexerItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderIndexerItems( + feedState: List, + postViewModel: IndexerRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt new file mode 100644 index 0000000000..8bbb81fa2f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.indexerRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveIndexerRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt deleted file mode 100644 index 8ad32dacbf..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt +++ /dev/null @@ -1,824 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3 - -import android.widget.Toast -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.SyncProblem -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -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.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.service.countToHumanReadable -import com.vitorpamplona.amethyst.service.countToHumanReadableBytes -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelayProposalSetupInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelaySetupInfoProposalDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.Size30Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.WarningColor -import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.warningColor -import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter -import kotlinx.coroutines.launch - -@Composable -fun Kind3RelayListView( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - onClose: () -> Unit, - nav: INav, - relayToAdd: String, -) { - val newNav = rememberExtendedNav(nav, onClose) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - LazyColumn(contentPadding = FeedPadding) { - renderKind3Items(feedState, postViewModel, accountViewModel, newNav, relayToAdd) - } - } -} - -fun LazyListScope.renderKind3Items( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - nav: INav, - relayToAdd: String, -) { - itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item -> - LoadRelayInfo( - item, - onToggleDownload = { postViewModel.toggleDownload(it) }, - onToggleUpload = { postViewModel.toggleUpload(it) }, - onToggleFollows = { postViewModel.toggleFollows(it) }, - onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, - onTogglePublicChats = { postViewModel.togglePublicChats(it) }, - onToggleGlobal = { postViewModel.toggleGlobal(it) }, - onToggleSearch = { postViewModel.toggleSearch(it) }, - onDelete = { postViewModel.deleteRelay(it) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - item { - Spacer(modifier = StdVertSpacer) - Kind3RelayEditBox(relayToAdd) { postViewModel.addRelay(it) } - } -} - -fun LazyListScope.renderKind3ProposalItems( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item -> - Kind3RelaySetupInfoProposalDialog( - item = item, - onAdd = { - postViewModel.addRelay(item) - }, - accountViewModel = accountViewModel, - nav = nav, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } -} - -@Preview -@Composable -fun ServerConfigPreview() { - ClickableRelayItem( - loadProfilePicture = true, - loadRobohash = false, - item = - Kind3BasicRelaySetupInfo( - url = "nostr.mom", - read = true, - write = true, - relayStat = - RelayStat( - errorCounter = 23, - receivedBytes = 10000, - sentBytes = 10000000, - spamCounter = 10, - ), - feedTypes = activeTypesGlobalChats, - paidRelay = true, - ), - onDelete = {}, - onToggleDownload = {}, - onToggleUpload = {}, - onToggleFollows = {}, - onTogglePrivateDMs = {}, - onTogglePublicChats = {}, - onToggleGlobal = {}, - onToggleSearch = {}, - onClick = {}, - ) -} - -@Composable -fun LoadRelayInfo( - item: Kind3BasicRelaySetupInfo, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - ClickableRelayItem( - item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch, - onDelete = onDelete, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ClickableRelayItem( - item: Kind3BasicRelaySetupInfo, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - onClick: () -> Unit, -) { - val clipboardManager = LocalClipboardManager.current - Column( - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - val relayInfo = Nip11CachedRetriever.getFromCache(item.url) - - RenderRelayIcon( - item.briefInfo.displayUrl, - relayInfo?.icon ?: item.briefInfo.favIcon, - loadProfilePicture, - loadRobohash, - item.relayStat.pingInMs, - LargeRelayIconModifier, - ) - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - FirstLine(item, onClick, onDelete, ReactionRowHeightChatMaxWidth) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChatMaxWidth, - ) { - ActiveToggles( - item = item, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChatMaxWidth, - ) { - StatusRow( - item = item, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - modifier = HalfStartPadding.weight(1f), - ) - } - } - } - - HorizontalDivider(thickness = DividerThickness) - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun StatusRow( - item: Kind3BasicRelaySetupInfo, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - modifier: Modifier, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(R.string.read_from_relay), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleDownload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.read_from_relay), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.receivedBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.Upload, - stringRes(R.string.write_to_relay), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleUpload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.write_to_relay), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.sentBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.SyncProblem, - stringRes(R.string.errors), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.errors), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.relayStat.errorCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) - - Text( - text = countToHumanReadable(item.relayStat.errorCounter, "errors"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.DeleteSweep, - stringRes(R.string.spam), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.spam), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.relayStat.spamCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) - - Text( - text = countToHumanReadable(item.relayStat.spamCounter, "spam"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun ActiveToggles( - item: Kind3BasicRelaySetupInfo, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - - Text( - text = stringRes(id = R.string.active_for), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp, end = 5.dp), - fontSize = 14.sp, - ) - - IconButton( - modifier = Size30Modifier, - onClick = { onToggleFollows(item) }, - ) { - Icon( - painterRes(R.drawable.ic_home), - stringRes(R.string.home_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleFollows(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.home_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.FOLLOWS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePrivateDMs(item) }, - ) { - Icon( - painterRes(R.drawable.ic_dm), - stringRes(R.string.private_message_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePrivateDMs(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.private_message_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePublicChats(item) }, - ) { - Icon( - imageVector = Icons.Default.Groups, - contentDescription = stringRes(R.string.public_chat_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePublicChats(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.public_chat_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleGlobal(item) }, - ) { - Icon( - imageVector = Icons.Default.Public, - stringRes(R.string.global_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleGlobal(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.global_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.GLOBAL)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun FirstLine( - item: Kind3BasicRelaySetupInfo, - onClick: () -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - modifier: Modifier, -) { - val clipboardManager = LocalClipboardManager.current - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - modifier = - Modifier.combinedClickable( - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) - }, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = Modifier.padding(start = 5.dp, top = 1.dp).size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } - - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = Modifier.padding(start = 10.dp).size(15.dp), - tint = WarningColor, - ) - } - } -} - -@Composable -fun Kind3RelayEditBox( - relayToAdd: String, - onNewRelay: (Kind3BasicRelaySetupInfo) -> Unit, -) { - var url by remember { mutableStateOf(relayToAdd) } - var read by remember { mutableStateOf(true) } - var write by remember { mutableStateOf(true) } - - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - ) - - IconButton(onClick = { read = !read }) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(id = R.string.read_from_relay), - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - IconButton(onClick = { write = !write }) { - Icon( - imageVector = Icons.Default.Upload, - contentDescription = stringRes(id = R.string.write_to_relay), - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - val normalized = RelayUrlFormatter.normalize(url) - onNewRelay( - Kind3BasicRelaySetupInfo( - url = normalized, - read = read, - write = write, - feedTypes = activeTypesGlobalChats, - relayStat = RelayStats.get(normalized), - ), - ) - url = "" - write = true - read = true - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - ) { - Text(text = stringRes(id = R.string.add), color = Color.White) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt deleted file mode 100644 index 70bf368a99..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3 - -import androidx.compose.runtime.Stable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.replace -import com.vitorpamplona.amethyst.service.togglePresenceInSet -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelayProposalSetupInfo -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -@Stable -class Kind3RelayListViewModel : ViewModel() { - private lateinit var account: Account - - private val _relays = MutableStateFlow>(emptyList()) - val relays = _relays.asStateFlow() - - private val _proposedRelays = MutableStateFlow>(emptyList()) - val proposedRelays = _proposedRelays.asStateFlow() - - var hasModified = false - - fun load(account: Account) { - this.account = account - clear() - loadRelayDocuments() - } - - fun create() { - if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - account.saveKind3RelayList( - relays.value.map { - RelaySetupInfo( - it.url, - it.read, - it.write, - it.feedTypes, - ) - }, - ) - clear() - } - } - } - - fun loadRelayDocuments() { - viewModelScope.launch(Dispatchers.IO) { - _relays.value.forEach { item -> - Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, - onInfo = { - togglePaidRelay(item, it.limitation?.payment_required ?: false) - }, - onError = { url, errorCode, exceptionMessage -> }, - ) - } - } - } - - fun clear() { - hasModified = false - _relays.update { - var relayFile = account.userProfile().latestContactList?.relays() - - if (relayFile != null) { - relayFile - .map { - val localInfoFeedTypes = - account.settings.localRelays - .filter { localRelay -> localRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: Constants.defaultRelays - .filter { defaultRelay -> defaultRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: activeTypesGlobalChats.toImmutableSet() - - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.key), - read = it.value.read, - write = it.value.write, - feedTypes = localInfoFeedTypes, - relayStat = RelayStats.get(it.key), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } else { - account.settings.localRelays - .map { - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = it.read, - write = it.write, - feedTypes = it.feedTypes, - relayStat = RelayStats.get(it.url), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } - } - - refreshProposals() - } - - private fun refreshProposals() { - _proposedRelays.update { - val proposed = - RelayListRecommendationProcessor - .reliableRelaySetFor( - account.liveKind3Follows.value.authors.mapNotNull { - account.getNIP65RelayList(it) - }, - relayUrlsToIgnore = - _relays.value.mapNotNullTo(HashSet()) { - if (it.read && FeedType.FOLLOWS in it.feedTypes) { - it.url - } else { - null - } - }, - hasOnionConnection = false, - ).sortedByDescending { it.users.size } - - proposed.mapNotNull { - if (it.requiredToNotMissEvents) { - Kind3RelayProposalSetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = true, - write = false, - feedTypes = setOf(FeedType.FOLLOWS), - relayStat = RelayStats.get(it.url), - users = it.users.sorted(), - ) - } else { - null - } - } - } - } - - fun addAll(defaultRelays: Array) { - hasModified = true - - _relays.update { - defaultRelays - .map { - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = it.read, - write = it.write, - feedTypes = it.feedTypes, - relayStat = RelayStats.get(it.url), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } - } - - fun addRelay(relay: Kind3BasicRelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return - - _relays.update { it.plus(relay) } - - refreshProposals() - - hasModified = true - } - - fun addRelay(relay: Kind3RelayProposalSetupInfo) { - if (relays.value.any { it.url == relay.url }) return - - _relays.update { - it.plus( - Kind3BasicRelaySetupInfo( - relay.url, - relay.read, - relay.write, - relay.feedTypes, - relay.relayStat, - relay.paidRelay, - ), - ) - } - - refreshProposals() - - hasModified = true - } - - fun deleteRelay(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.minus(relay) } - - refreshProposals() - - hasModified = true - } - - fun deleteAll() { - _relays.update { relays -> emptyList() } - - refreshProposals() - - hasModified = true - } - - fun toggleDownload(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.replace(relay, relay.copy(read = !relay.read)) } - hasModified = true - } - - fun toggleUpload(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.replace(relay, relay.copy(write = !relay.write)) } - hasModified = true - } - - fun toggleFollows(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.FOLLOWS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleMessages(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.PRIVATE_DMS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun togglePublicChats(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.PUBLIC_CHATS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleGlobal(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.GLOBAL) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleSearch(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.SEARCH) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun togglePaidRelay( - relay: Kind3BasicRelaySetupInfo, - paid: Boolean, - ) { - _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt index d11d524a73..01a9fe26d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -64,7 +64,7 @@ fun LazyListScope.renderLocalItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Local" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index d674a2cb74..496190e8eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class LocalRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List = account.settings.localRelayServers.toList() + override fun getRelayList(): List = + account.localRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { - account.settings.updateLocalRelayServers(urlList.toSet()) + override suspend fun saveRelayList(urlList: List) { + account.localRelayList.saveRelayList(urlList) {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index a159087aee..e447b5a0ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -64,7 +64,7 @@ fun LazyListScope.renderPrivateOutboxItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index e836022181..d90292c232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getPrivateOutboxRelayList()?.relays() + override fun getRelayList(): List? = + account.privateStorageRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.savePrivateOutboxRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 5d3c494625..12be77dfe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -67,7 +67,7 @@ fun LazyListScope.renderNip65HomeItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, @@ -88,7 +88,7 @@ fun LazyListScope.renderNip65NotifItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 9fcde2682e..c2d82c8c8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,6 +40,7 @@ import kotlinx.coroutines.launch @Stable class Nip65RelayListViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account private val _homeRelays = MutableStateFlow>(emptyList()) @@ -48,17 +51,21 @@ class Nip65RelayListViewModel : ViewModel() { var hasModified = false - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { clear() loadRelayDocuments() } fun create() { if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - val writes = _homeRelays.value.map { it.url }.toSet() - val reads = _notificationRelays.value.map { it.url }.toSet() + accountViewModel.runIOCatching { + val writes = _homeRelays.value.map { it.relay }.toSet() + val reads = _notificationRelays.value.map { it.relay }.toSet() val urls = writes.union(reads) @@ -66,13 +73,14 @@ class Nip65RelayListViewModel : ViewModel() { urls.map { val type = if (writes.contains(it) && reads.contains(it)) { - AdvertisedRelayListEvent.AdvertisedRelayType.BOTH + AdvertisedRelayType.BOTH } else if (writes.contains(it)) { - AdvertisedRelayListEvent.AdvertisedRelayType.WRITE + AdvertisedRelayType.WRITE } else { - AdvertisedRelayListEvent.AdvertisedRelayType.READ + AdvertisedRelayType.READ } - AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type) + + AdvertisedRelayInfo(it, type) }, ) clear() @@ -84,8 +92,8 @@ class Nip65RelayListViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _homeRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) }, onInfo = { toggleHomePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -95,8 +103,8 @@ class Nip65RelayListViewModel : ViewModel() { _notificationRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) }, onInfo = { toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -109,28 +117,28 @@ class Nip65RelayListViewModel : ViewModel() { fun clear() { hasModified = false _homeRelays.update { - val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList() + val relayList = account.nip65RelayList.getNIP65RelayList()?.writeRelaysNorm() ?: emptyList() relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } _notificationRelays.update { - val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList() + val relayList = account.nip65RelayList.getNIP65RelayList()?.readRelaysNorm() ?: emptyList() relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } } fun addHomeRelay(relay: BasicRelaySetupInfo) { - if (_homeRelays.value.any { it.url == relay.url }) return + if (_homeRelays.value.any { it.relay == relay.relay }) return _homeRelays.update { it.plus(relay) } hasModified = true @@ -154,7 +162,7 @@ class Nip65RelayListViewModel : ViewModel() { } fun addNotifRelay(relay: BasicRelaySetupInfo) { - if (_notificationRelays.value.any { it.url == relay.url }) return + if (_notificationRelays.value.any { it.relay == relay.relay }) return _notificationRelays.update { it.plus(relay) } hasModified = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt new file mode 100644 index 0000000000..1580a41150 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun ProxyRelayList( + postViewModel: ProxyRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderProxyItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderProxyItems( + feedState: List, + postViewModel: ProxyRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt new file mode 100644 index 0000000000..5985a0c14d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.proxyRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveProxyRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt deleted file mode 100644 index 5e720bd5dc..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache - -@Composable -fun Kind3RelaySetupInfoProposalDialog( - item: Kind3RelayProposalSetupInfo, - onAdd: (Kind3RelayProposalSetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Kind3RelaySetupInfoProposalRow( - item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onAdd = { - onAdd(item) - }, - accountViewModel = accountViewModel, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - nav = nav, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt deleted file mode 100644 index f04bdbeef7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.AddRelayButton -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun Kind3RelaySetupInfoProposalRow( - item: Kind3RelayProposalSetupInfo, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onAdd: () -> Unit, - onClick: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column( - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - val relayInfo by loadRelayInfo(item.url, accountViewModel) - - RenderRelayIcon( - item.briefInfo.displayUrl, - relayInfo?.icon ?: item.briefInfo.favIcon, - loadProfilePicture, - loadRobohash, - item.relayStat.pingInMs, - LargeRelayIconModifier, - ) - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - Row(ReactionRowHeightChatMaxWidth, verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = - Modifier - .padding(start = 5.dp, top = 1.dp) - .size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } - } - - UsedBy(item, accountViewModel, nav) - - Column( - Modifier - .padding(start = 10.dp), - ) { - AddRelayButton(onAdd) - } - } - - HorizontalDivider(thickness = DividerThickness) - } -} - -@Preview -@Composable -fun UsedByPreview() { - ThemeComparisonColumn { - UsedBy( - item = - Kind3RelayProposalSetupInfo( - "wss://nos.lol", - true, - true, - COMMON_FEED_TYPES, - relayStat = RelayStat(), - paidRelay = false, - users = listOf("User1", "User2", "User3", "User4"), - ), - accountViewModel = mockAccountViewModel(), - nav = EmptyNav, - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun UsedBy( - item: Kind3RelayProposalSetupInfo, - accountViewModel: AccountViewModel, - nav: INav, -) { - FlowRow(verticalArrangement = Arrangement.Center) { - item.users.getOrNull(0)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - item.users.getOrNull(1)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - item.users.getOrNull(2)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - if (item.users.size > 3) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.height(Size25dp)) { - Text( - text = stringRes(R.string.and_more, item.users.size - 3), - maxLines = 1, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index bc2cc0c731..139e5344c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -65,7 +65,7 @@ fun LazyListScope.renderSearchItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Search" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Search" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index d489072bbb..4fce997dd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SearchRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getSearchRelayList()?.relays() + override fun getRelayList(): List? = + account.searchRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.saveSearchRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt new file mode 100644 index 0000000000..355506f36b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun TrustedRelayList( + postViewModel: TrustedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderTrustedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderTrustedItems( + feedState: List, + postViewModel: TrustedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Trusted" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt new file mode 100644 index 0000000000..1d1f718828 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class TrustedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.trustedRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveTrustedRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt index ca85db9de1..663b21dd72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt @@ -66,7 +66,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.quartz.nip56Reports.ReportType import kotlinx.collections.immutable.toImmutableList @@ -195,7 +195,7 @@ private fun ActionButton( ) = Button( onClick = onClick, enabled = enabled, - colors = ButtonDefaults.buttonColors(containerColor = WarningColor), + colors = ButtonDefaults.buttonColors(containerColor = LightRedColor), modifier = Modifier.fillMaxWidth(), ) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt index 14b4f8c4a4..b82d461cd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt @@ -37,11 +37,10 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -50,8 +49,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent @Preview @Composable @@ -69,46 +66,14 @@ fun ObserveRelayListForSearchAndDisplayIfNotFound( accountViewModel: AccountViewModel, nav: INav, ) { - ObserveRelayListForSearch( - accountViewModel = accountViewModel, - ) { relayListEvent -> - if (relayListEvent == null || relayListEvent.relays().isEmpty()) { - AddInboxRelayForSearchCard( - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} + val searchRelayList by accountViewModel.account.searchRelayList.flow + .collectAsStateWithLifecycle() -@Composable -fun ObserveRelayListForSearch( - accountViewModel: AccountViewModel, - inner: @Composable (relayListEvent: SearchRelayListEvent?) -> Unit, -) { - ObserveRelayListForSearch( - pubkey = accountViewModel.account.userProfile().pubkeyHex, - accountViewModel = accountViewModel, - ) { relayListEvent -> - inner(relayListEvent) - } -} - -@Composable -fun ObserveRelayListForSearch( - pubkey: HexKey, - accountViewModel: AccountViewModel, - inner: @Composable (relayListEvent: SearchRelayListEvent?) -> Unit, -) { - LoadAddressableNote( - SearchRelayListEvent.createAddressTag(pubkey), - accountViewModel, - ) { relayList -> - if (relayList != null) { - val relayListEvent by observeNoteEvent(relayList, accountViewModel) - - inner(relayListEvent) - } + if (searchRelayList.isEmpty()) { + AddInboxRelayForSearchCard( + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index 2648cc4f74..651223d18a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,11 +30,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -45,15 +40,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayList import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier @@ -66,7 +59,11 @@ fun AddSearchRelayListDialog( ) { val postViewModel: SearchRelayListViewModel = viewModel() - LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel.account) { + postViewModel.load() + } Dialog( onDismissRequest = onClose, @@ -75,39 +72,16 @@ fun AddSearchRelayListDialog( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Text(stringRes(R.string.search_relays_title)) - - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.search_relays_title, + onCancel = { + postViewModel.clear() + onClose() }, - navigationIcon = { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) + onPost = { + postViewModel.create() + onClose() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 6ca95b192f..b6241290c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState @@ -68,39 +67,61 @@ class SearchBarViewModel( .onEach(::updateDataSource) .stateIn(viewModelScope, SharingStarted.Eagerly, searchValue) - val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue)) + val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) val searchResultsUsers = - combine(searchValueFlow.debounce(100), invalidations.debounce(100)) { term, version -> - logTime("SearchBarViewModel findUsersStartingWith") { - LocalCache.findUsersStartingWith(term, account) - } + combine( + searchValueFlow.debounce(100), + invalidations.debounce(100), + ) { term, version -> + LocalCache.findUsersStartingWith(term, account) }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) val searchResultsNotes = - combine(searchValueFlow.debounce(100), invalidations) { term, version -> - logTime("SearchBarViewModel findNotesStartingWith") { - LocalCache - .findNotesStartingWith(term, account) - .sortedWith(DefaultFeedOrder) - } + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache + .findNotesStartingWith(term, account.hiddenUsers) + .sortedWith(DefaultFeedOrder) }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) - val searchResultsChannels = - combine(searchValueFlow.debounce(100), invalidations) { term, version -> - logTime("SearchBarViewModel findChannelsStartingWith") { - LocalCache.findChannelsStartingWith(term) - } + val searchResultsPublicChatChannels = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache.findPublicChatChannelsStartingWith(term) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsEphemeralChannels = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache.findEphemeralChatChannelsStartingWith(term) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsLiveActivityChannels = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache.findLiveActivityChannelsStartingWith(term) }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) val hashtagResults = - combine(searchValueFlow.debounce(100), invalidations) { term, version -> - logTime("SearchBarViewModel findHashtags") { - findHashtags(term) - } + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + findHashtags(term) }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 4f150f9f24..adc9244d6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -60,14 +60,16 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -75,7 +77,9 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.launch @Composable fun SearchScreen( @@ -140,9 +144,19 @@ private fun SearchBar( TextSearchDataSourceSubscription(searchBarViewModel, accountViewModel) LaunchedEffect(Unit) { - LocalCache.live.newEventBundles.collect { - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() + launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } + } + } + + launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } } } } @@ -230,7 +244,9 @@ private fun DisplaySearchResults( val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() + val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() + val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() + val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() LazyColumn( @@ -262,8 +278,8 @@ private fun DisplaySearchResults( } itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex }, + publicChatChannels, + key = { _, item -> "public" + item.idHex }, ) { _, item -> ChannelName( channelIdHex = item.idHex, @@ -279,7 +295,63 @@ private fun DisplaySearchResults( hasNewMessages = false, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onClick = { nav.nav(Route.Channel(item.idHex)) }, + onClick = { nav.nav(routeFor(item)) }, + ) + + HorizontalDivider( + modifier = StdTopPadding, + thickness = DividerThickness, + ) + } + + itemsIndexed( + ephemeralChannels, + key = { _, item -> "ephem" + item.roomId.toKey() }, + ) { _, item -> + val relayInfo by loadRelayInfo(item.roomId.relayUrl, accountViewModel) + + ChannelName( + channelIdHex = item.roomId.toKey(), + channelPicture = relayInfo.icon, + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = stringRes(R.string.ephemeral_relay_chat), + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + onClick = { nav.nav(routeFor(item)) }, + ) + + HorizontalDivider( + modifier = StdTopPadding, + thickness = DividerThickness, + ) + } + + itemsIndexed( + liveActivityChannels, + key = { _, item -> "live" + item.address.toValue() }, + ) { _, item -> + ChannelName( + channelIdHex = item.address.toValue(), + channelPicture = item.profilePicture(), + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = item.summary(), + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + onClick = { nav.nav(routeFor(item)) }, ) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 5eb38c0aaf..64523f48d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -57,8 +57,8 @@ import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 3b821ffe74..6507271c18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -23,22 +23,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes @Composable fun NIP47SetupScreen( @@ -47,7 +42,12 @@ fun NIP47SetupScreen( nip47: String?, ) { val postViewModel: UpdateZapAmountViewModel = viewModel() - postViewModel.load(accountViewModel.account) + postViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel, postViewModel) { + postViewModel.load() + } + NIP47SetupScreen(postViewModel, accountViewModel, nav, nip47) } @@ -61,28 +61,16 @@ fun NIP47SetupScreen( ) { Scaffold( topBar = { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), - title = { Text(stringRes(id = R.string.wallet_connect)) }, - navigationIcon = { - IconButton( - onClick = { - postViewModel.cancel() - nav.popBack() - }, - modifier = Modifier, - ) { - ArrowBackIcon() - } + SavingTopBar( + titleRes = R.string.wallet_connect, + isActive = postViewModel::hasChanged, + onCancel = { + postViewModel.cancel() + nav.popBack() }, - actions = { - SaveButton( - onPost = { - postViewModel.sendPost() - nav.popBack() - }, - isActive = postViewModel.hasChanged(), - ) + onPost = { + postViewModel.sendPost() + nav.popBack() }, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index eb0fdfa253..02b5a801c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -70,8 +70,8 @@ import com.vitorpamplona.amethyst.model.parseWarningType import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.elements.AddButton import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -264,7 +264,7 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) { placeholder = selectedItens[parseWarningType(sensitive).screenCode].title, options = selectedItens, onSelect = { - accountViewModel.updateContentWarnings(parseWarningType(it)) + accountViewModel.updateShowSensitiveContent(parseWarningType(it).prefCode) }, ) } @@ -335,8 +335,10 @@ fun WatchAccountAndBlockList( accountViewModel: AccountViewModel, invalidate: () -> Unit, ) { - val transientSpammers by accountViewModel.account.transientHiddenUsers.collectAsStateWithLifecycle() - val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers + .collectAsStateWithLifecycle() + val blockListState by accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, transientSpammers, blockListState) { invalidate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index 7f1abdfa5a..607d494b9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -98,26 +97,25 @@ open class StringFeedViewModel( } } - var collectorJob: Job? = null - init { Log.d("Init", this.javaClass.simpleName) - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() } + } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index 9f57b1af5d..60d35bcfc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -46,20 +46,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import java.util.Locale as JavaLocale @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @@ -129,11 +126,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) { DropdownMenuItem( text = { Text(text = JavaLocale.forLanguageTag(languageCode).displayName) }, onClick = { - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.toggleDontTranslateFrom( - languageCode, - ) - } + accountViewModel.toggleDontTranslateFrom(languageCode) selectedLanguages.remove(languageCode) expanded = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt index 76fb9f019d..b1c001bb8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt @@ -35,7 +35,7 @@ class HiddenAccountsFeedFilter( override fun showHiddenKey(): Boolean = true override fun feed(): List = - account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull { + account.hiddenUsers.flow.value.hiddenUsers.reversed().mapNotNull { try { LocalCache.getOrCreateUser(it) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt index 59ffe6cb8d..ec9b509767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt @@ -31,6 +31,6 @@ class HiddenWordsFeedFilter( override fun showHiddenKey(): Boolean = true override fun feed(): List = - account.flowHiddenUsers.value.hiddenWords + account.hiddenUsers.flow.value.hiddenWords .toList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt index 8cf06a1d4a..542fa1f921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt @@ -32,5 +32,7 @@ class SpammerAccountsFeedFilter( override fun showHiddenKey(): Boolean = true - override fun feed(): List = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) } + override fun feed(): List = + account.hiddenUsers.transientHiddenUsers.value + .map { LocalCache.getOrCreateUser(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 8e9c54cb39..318672bd9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -83,10 +83,10 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.DisplayDraft @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation @@ -117,12 +118,17 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay @@ -142,15 +148,17 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay +import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel @@ -178,6 +186,7 @@ import com.vitorpamplona.quartz.experimental.forks.forkFromAddress import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind @@ -201,10 +210,15 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup @@ -218,6 +232,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -479,7 +494,7 @@ private fun FullBleedNoteCompose( val geo = remember { noteEvent.geoHashOrScope() } if (geo != null) { - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } } @@ -516,21 +531,28 @@ private fun FullBleedNoteCompose( .padding(horizontal = 12.dp), ) { Column { - if ( - (noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && - baseNote.channelHex() != null - ) { - ChannelHeader( - channelHex = baseNote.channelHex()!!, - showVideo = true, + if (noteEvent is ChannelCreateEvent) { + PublicChatChannelHeader( + channelHex = noteEvent.id, sendToChannel = true, accountViewModel = accountViewModel, nav = nav, ) + } else if (noteEvent is ChannelMetadataEvent) { + noteEvent.channelId()?.let { + PublicChatChannelHeader( + channelHex = it, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } } else if (noteEvent is VideoEvent) { VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, ContentScale.FillWidth, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is PictureEvent) { PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav) + } else if (noteEvent is BaseVoiceEvent) { + VoiceHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { @@ -589,6 +611,16 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is BlockedRelayListEvent) { + DisplayBlockedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is ProxyRelayListEvent) { + DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is TrustedRelayListEvent) { + DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is IndexerRelayListEvent) { + DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is BroadcastRelayListEvent) { + DisplayBroadcastRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is FhirResourceEvent) { RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { @@ -613,6 +645,8 @@ private fun FullBleedNoteCompose( RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PublicMessageEvent) { + RenderPublicMessage(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommentEvent) { RenderTextEvent( baseNote, @@ -849,7 +883,7 @@ private fun RenderClassifiedsReaderForThread( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterRes(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 5), stringRes(R.string.send_a_direct_message), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary, @@ -944,6 +978,7 @@ private fun RenderLongFormHeaderForThread( mainImageModifier = Modifier, loadedImageModifier = MaterialTheme.colorScheme.imageModifier, accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt index 5f2158f6c4..4ee2396a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt @@ -31,8 +31,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssemblerSubscription @@ -53,7 +53,7 @@ fun ThreadScreen( ) WatchLifecycleAndUpdateModel(feedViewModel) - ThreadFilterAssemblerSubscription(noteId, accountViewModel.dataSources().thread) + ThreadFilterAssemblerSubscription(noteId, accountViewModel) LoadNote(noteId, accountViewModel) { if (it != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt index 5f94ccae19..f1ae4c13a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt @@ -39,7 +39,7 @@ class ThreadFeedFilter( override fun feed(): List { val cachedSignatures: MutableMap = mutableMapOf() - val followingKeySet = account.liveKind3Follows.value.authors + val followingKeySet = account.kind3FollowList.flow.value.authors val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() // Filter out drafts made by other accounts on device diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt index 11c3948c87..1ac82c64e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -20,15 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ThreadQueryState( val eventId: HexKey, + val account: Account, ) class ThreadFilterAssembler( @@ -40,15 +42,11 @@ class ThreadFilterAssembler( ThreadEventLoaderSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt index c0407747b8..7954bda887 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -22,19 +22,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun ThreadFilterAssemblerSubscription( eventId: HexKey, + accountViewModel: AccountViewModel, +) = ThreadFilterAssemblerSubscription( + eventId, + accountViewModel.account, + accountViewModel.dataSources().thread, +) + +@Composable +fun ThreadFilterAssemblerSubscription( + eventId: HexKey, + account: Account, filterAssembler: ThreadFilterAssembler, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(eventId) { - ThreadQueryState(eventId) + ThreadQueryState(eventId, account) } KeyDataSourceSubscription(state, filterAssembler) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt index 0e896d20cb..c0cabe9fff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt @@ -22,65 +22,68 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.sub import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterEventsInThreadForRoot( root: Note, - since: Map?, -): List { + since: SincePerRelayMap?, +): List { val addressRoot = if (root is AddressableNote) root.idHex else null val eventRoot = if (root !is AddressableNote) root.idHex else root.event?.id - val address = - if (addressRoot != null) { - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("a" to listOf(addressRoot)), - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("A" to listOf(addressRoot)), - since = since, - ), - ), - ) - } else { - emptyList() - } + return root.relayUrlsForReactions().toSet().flatMap { + val since = since?.get(it)?.time - val event = - if (eventRoot != null) { - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("e" to listOf(eventRoot)), - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("E" to listOf(eventRoot)), - since = since, - ), - ), - ) - } else { - emptyList() - } + val addressList = + if (addressRoot != null) { + listOf( + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("a" to listOf(addressRoot)), + since = since, + ), + ), + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("A" to listOf(addressRoot)), + since = since, + ), + ), + ) + } else { + emptyList() + } - return address + event + val eventList = + if (eventRoot != null) { + listOf( + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("e" to listOf(eventRoot)), + since = since, + ), + ), + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("E" to listOf(eventRoot)), + since = since, + ), + ), + ) + } else { + emptyList() + } + + addressList + eventList + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt index 975844cf6a..8e0ad15d1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt @@ -24,33 +24,52 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterMissingEventsForThread(threadInfo: ThreadAssembler.ThreadInfo): List { - val missingEvents = mutableSetOf() - val missingAddresses = mutableSetOf
() +fun filterMissingEventsForThread( + threadInfo: ThreadAssembler.ThreadInfo, + defaultRelays: Set, +): List { + val missingEvents = + mapOfSet { + if (threadInfo.root.event == null && threadInfo.root !is AddressableNote) { + potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, threadInfo.root.idHex) + } + } - if (threadInfo.root.event == null) { - if (threadInfo.root is AddressableNote) { - missingAddresses.add(threadInfo.root.address) - } else { - missingEvents.add(threadInfo.root.idHex) - } - } - - threadInfo.allNotes.forEach { - if (it.event == null) { - if (it is AddressableNote) { - missingAddresses.add(it.address) - } else { - missingEvents.add(it.idHex) + threadInfo.allNotes.forEach { + if (it !is AddressableNote && it.event == null) { + potentialRelaysToFindEvent(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, it.idHex) + } + } } } - } - val missingEventsFilter = filterMissingEvents(missingEvents) ?: emptyList() - val missingAddressFilter = filterMissingAddressables(missingAddresses) ?: emptyList() + val missingAddresses = + mapOfSet { + if (threadInfo.root.event == null && threadInfo.root is AddressableNote) { + potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, threadInfo.root.address) + } + } + + threadInfo.allNotes.forEach { + if (it is AddressableNote && it.event == null) { + potentialRelaysToFindAddress(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, it.address) + } + } + } + } + + val missingEventsFilter = filterMissingEvents(missingEvents) + val missingAddressFilter = filterMissingAddressables(missingAddresses) return missingEventsFilter + missingAddressFilter } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt index 0426f813c3..cc3e8a12c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.sub import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * Loads all missing events in each thread. @@ -39,13 +40,14 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime class ThreadEventLoaderSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys, invalidateAfterEose = true) { +) : PerUniqueIdEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter( key: ThreadQueryState, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { val branches = ThreadAssembler().findThreadFor(key.eventId) ?: return null - return filterMissingEventsForThread(branches) + val defaultRelays = key.account.followPlusAllMine.flow.value + return filterMissingEventsForThread(branches, defaultRelays) } override fun id(key: ThreadQueryState) = key.eventId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt index be35518598..f65a9d330b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.sub import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * Loads all events in the Thread that cites the root post. @@ -36,11 +37,11 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime class ThreadFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ThreadQueryState, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { val root = ThreadAssembler().findRoot(key.eventId) ?: return null return filterEventsInThreadForRoot(root, since) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt index ce2a49a975..c5bb56067d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt @@ -53,10 +53,11 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -178,7 +179,7 @@ fun NewImageButton( Icon( imageVector = Icons.Outlined.Close, contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } @@ -189,9 +190,9 @@ fun NewImageButton( exit = fadeOut(), ) { Icon( - painter = painterRes(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 5), contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index 9a8b7f8943..25f33b6006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -23,21 +23,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun StoriesTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultStoriesFollowList .collectAsStateWithLifecycle() - FollowListWithRoutes( + FollowList( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, accountViewModel = accountViewModel, @@ -46,3 +50,21 @@ fun StoriesTopBar( } } } + +@Composable +private fun FollowList( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index 1245283f1c..0d6e5aafe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -68,10 +67,10 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BoostReaction import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -90,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilte import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AuthorInfoVideoFeed import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfFeedPadding import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size35Modifier @@ -164,7 +164,9 @@ fun WatchAccountForVideoScreen( accountViewModel: AccountViewModel, ) { val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState, hiddenUsers) { videoFeedContentState.checkKeysInvalidateDataAndSendToTop() @@ -279,8 +281,7 @@ private fun RenderVideoOrPictureNote( val noteEvent = remember { note.event } if (noteEvent is PictureEvent) { val backgroundColor = remember { mutableStateOf(Color.Transparent) } - - PictureDisplay(note, false, ContentScale.Fit, PaddingValues(5.dp), backgroundColor, accountViewModel, nav) + PictureDisplay(note, false, ContentScale.Fit, HalfFeedPadding, backgroundColor, accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note, false, ContentScale.Fit, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { @@ -420,7 +421,7 @@ fun ReactionsColumn( ) { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } } BoostReaction( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt new file mode 100644 index 0000000000..cd41fb2005 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal + +class SupportedContent( + val blockedUrls: List, + val mimeTypes: Set, + val supportedFileExtensions: Set, +) { + private fun validExtension(fullUrl: String): Boolean { + val queryIndex = fullUrl.indexOf('?') + if (queryIndex > 0) { + return supportedFileExtensions.any { fullUrl.startsWith(it, queryIndex - it.length) } + } + + val fragmentIndex = fullUrl.indexOf('#') + if (fragmentIndex > 0) { + return supportedFileExtensions.any { fullUrl.startsWith(it, fragmentIndex - it.length) } + } + + return supportedFileExtensions.any { fullUrl.endsWith(it) } + } + + fun acceptableUrl( + url: String, + mimeType: String?, + ) = blockedUrls.none { url.contains(it) } && ((mimeType != null && mimeTypes.contains(mimeType)) || validExtension(url)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index ec72c56339..b0744b1230 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -31,9 +31,10 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent @@ -42,8 +43,17 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent class VideoFeedFilter( val account: Account, ) : AdditiveFeedFilter() { + val videoFeedSupport = + SupportedContent( + blockedUrls = listOf("youtu.be", "youtube.com"), + mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, + supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(), + ) + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value + override fun limit() = 300 + override fun showHiddenKey(): Boolean = account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) @@ -73,25 +83,19 @@ class VideoFeedFilter( fun acceptableUrls( baseUrls: List, mimeType: String?, - ): Boolean { - // we don't have an youtube player - val urls = baseUrls.filter { !(it.contains("youtu.be") || it.contains("youtube.com")) } + ) = baseUrls.any { videoFeedSupport.acceptableUrl(it, mimeType) } - val isSupportedMimeType = mimeType?.let { SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it) } == true + fun acceptableVideoiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } - return urls.isNotEmpty() && (urls.any { RichTextParser.Companion.isImageOrVideoUrl(it) } || isSupportedMimeType) - } + fun acceptablePictureiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } - fun acceptableiMetas(iMetas: List): Boolean = - iMetas.any { - !it.url.contains("youtu.be") && !it.url.contains("youtube.com") && (RichTextParser.Companion.isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType))) - } + fun acceptanceEvent(noteEvent: PictureEvent) = acceptablePictureiMetas(noteEvent.imetaTags()) fun acceptanceEvent(noteEvent: FileHeaderEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType()) - fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) - fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) fun acceptableEvent( note: Note, @@ -107,13 +111,8 @@ class VideoFeedFilter( (noteEvent is FileHeaderEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) || - ( - noteEvent is FileStorageHeaderEvent && - noteEvent.isOneOf( - SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, - ) - ) || - noteEvent is PictureEvent + (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + (noteEvent is PictureEvent && acceptanceEvent(noteEvent)) ) && params.match(noteEvent) && (params.isHiddenList || account.isAcceptable(note)) @@ -121,10 +120,8 @@ class VideoFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByGeohash.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt index 04d88894d4..6915b01989 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt @@ -18,51 +18,21 @@ * 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.video.datasource.subassemblies +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -fun filterPictureAndVideoByGeohash( - hashToLoad: Set?, - since: Map?, -): List { - if (hashToLoad == null || hashToLoad.isEmpty()) return emptyList() +val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif") +val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet() - val geoHashes = hashToLoad.toList() +val PictureAndVideoKinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND) +val PictureAndVideoKTags = listOf(PictureEvent.KIND.toString(), VideoHorizontalEvent.KIND.toString(), VideoVerticalEvent.KIND.toString()) - return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = mapOf("g" to geoHashes), - limit = 100, - since = since, - ), - ), - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "g" to geoHashes, - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = since, - ), - ), - ) -} +val PictureAndVideoLegacyKinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND) +val PictureAndVideoLegacyKTags = listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()) +val LegacyMimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES +val LegacyMimeTypeMap = mapOf("m" to LegacyMimeTypes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt index f1dbb2a70d..11fba18b78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -22,17 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoMixGeohashHashtagsFilterSubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope -val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif") -val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet() - // This allows multiple screen to be listening to tags, even the same tag class VideoQueryState( val account: Account, + val feedState: AccountFeedContentStates, val scope: CoroutineScope, ) @@ -42,18 +40,13 @@ class VideoFilterAssembler( val group = listOf( VideoOutboxEventsFilterSubAssembler(client, ::allKeys), - VideoMixGeohashHashtagsFilterSubAssembler(client, ::allKeys), ) - override fun start() = group.forEach { it.start() } - - override fun stop() = group.forEach { it.stop() } - override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } - override fun destroy() = group.forEach { it.start() } + override fun destroy() = group.forEach { it.destroy() } override fun printStats() = group.forEach { it.printStats() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt index 004a72639e..691cb8dbbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -43,7 +43,7 @@ fun VideoFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel.account) { - VideoQueryState(accountViewModel.account, accountViewModel.viewModelScope) + VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } KeyDataSourceSubscription(state, filterAssembler) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt deleted file mode 100644 index 392774e8b5..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies - -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent - -fun filterPictureAndVideoByFollows( - follows: Map>?, - since: Map?, -): List { - if (follows != null && follows.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - limit = 200, - since = since, - ), - ), - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - limit = 200, - tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt deleted file mode 100644 index bcabeff4b6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class VideoMixGeohashHashtagsFilterSubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: VideoQueryState, - since: Map?, - ): List? = - filterPictureAndVideoByHashtag( - key.followLists()?.hashtags, - since, - ) + - filterPictureAndVideoByGeohash( - key.followLists()?.geotags, - since, - ) - - override fun user(key: VideoQueryState) = key.account.userProfile() - - fun VideoQueryState.followListsFlow() = account.liveStoriesFollowLists - - fun VideoQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: VideoQueryState): Subscription { - userJobMap[key.account.userProfile()]?.cancel() - userJobMap[key.account.userProfile()] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index 0cc3cbd003..99714ded78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -21,12 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows.filterPictureAndVideoByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows.filterPictureAndVideoByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoByCommunity +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -37,11 +52,25 @@ import kotlinx.coroutines.launch class VideoOutboxEventsFilterSubAssembler( client: NostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: VideoQueryState, - since: Map?, - ): List? = filterPictureAndVideoByFollows(key.followsPerRelay(), since) + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + val defaultSince = key.feedState.videoFeed.lastNoteCreatedAtWhenFullyLoaded.value + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } + } override fun user(key: VideoQueryState) = key.account.userProfile() @@ -51,11 +80,7 @@ class VideoOutboxEventsFilterSubAssembler( fun VideoQueryState.listName() = listNameFlow().value - fun VideoQueryState.followListsFlow() = account.liveStoriesFollowLists - - fun VideoQueryState.followLists() = followListsFlow().value - - fun VideoQueryState.followsPerRelayFlow() = account.liveStoriesListAuthorsPerRelay + fun VideoQueryState.followsPerRelayFlow() = account.liveStoriesFollowListsPerRelay fun VideoQueryState.followsPerRelay() = followsPerRelayFlow().value @@ -68,12 +93,12 @@ class VideoOutboxEventsFilterSubAssembler( userJobMap[user] = listOf( key.scope.launch(Dispatchers.Default) { - key.listNameFlow().collectLatest { + key.followsPerRelayFlow().sample(1000).collectLatest { invalidateFilters() } }, key.scope.launch(Dispatchers.Default) { - key.followsPerRelayFlow().sample(5000).collectLatest { + key.feedState.videoFeed.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { invalidateFilters() } }, @@ -86,7 +111,7 @@ class VideoOutboxEventsFilterSubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt new file mode 100644 index 0000000000..5cbc0db864 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPictureAndVideoGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoKinds, + tags = mapOf("g" to geoHashes), + limit = 200, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = + mapOf( + "g" to geoHashes, + "m" to LegacyMimeTypes, + ), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPictureAndVideoGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt index a3be33c21c..1b7bff4be4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt @@ -18,51 +18,45 @@ * 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.video.datasource.subassemblies +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import kotlin.collections.flatten -fun filterPictureAndVideoByHashtag( - hashToLoad: Set?, - since: Map?, -): List { - if (hashToLoad == null || hashToLoad.isEmpty()) return emptyList() - - val hashtags = hashToLoad.map { hashtagAlts(it) }.flatten().distinct() - - if (hashtags.isEmpty()) return emptyList() +fun filterPictureAndVideoHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long? = null, +): List { + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + Filter( + kinds = PictureAndVideoKinds, tags = mapOf("t" to hashtags), limit = 100, since = since, ), ), - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + Filter( + kinds = PictureAndVideoLegacyKinds, tags = mapOf( "t" to hashtags, - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, + "m" to LegacyMimeTypes, ), limit = 100, since = since, @@ -70,3 +64,24 @@ fun filterPictureAndVideoByHashtag( ), ) } + +fun filterPictureAndVideoByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPictureAndVideoHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt new file mode 100644 index 0000000000..a5c03b7ee6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterPictureAndVideoGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = PictureAndVideoKinds, + limit = 50, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = LegacyMimeTypeMap, + limit = 50, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt new file mode 100644 index 0000000000..29dc11fb22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.math.max + +fun filterPictureAndVideoAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = PictureAndVideoKinds, + limit = if (since == null) max(authorList.size * 20, 200) else null, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = PictureAndVideoLegacyKinds, + tags = LegacyMimeTypeMap, + limit = if (since == null) max(authorList.size * 20, 200) else null, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPictureAndVideoAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPictureAndVideoByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPictureAndVideoAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt new file mode 100644 index 0000000000..368b25f72e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPictureAndVideoByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { authors -> + filterPictureAndVideoAuthors(relay, authors, since) + }, + it.value.geotags?.let { geotags -> + filterPictureAndVideoGeohash(relay, geotags, since) + }, + it.value.hashtags?.let { hashtags -> + filterPictureAndVideoHashtag(relay, hashtags, since) + }, + it.value.communities?.let { communities -> + filterPictureAndVideoAllCommunities(relay, communities, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt new file mode 100644 index 0000000000..b7be4a067f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent + +fun filterPictureAndVideoAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to PictureAndVideoKTags, + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = PictureAndVideoKinds, + limit = 200, + since = since, + ), + ), + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()), + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = + mapOf( + "a" to communityList, + "m" to LegacyMimeTypes, + ), + kinds = PictureAndVideoLegacyKinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPictureAndVideoAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt new file mode 100644 index 0000000000..a5dc40bcbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPictureAndVideoCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to PictureAndVideoKTags, + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = PictureAndVideoKinds, + limit = 200, + since = since, + ), + ), + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to PictureAndVideoLegacyKTags, + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = + mapOf( + "a" to listOf(community), + "m" to LegacyMimeTypes, + ), + kinds = PictureAndVideoLegacyKinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPictureAndVideoCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt index 783e7557b7..a1210e56a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginPage +import com.vitorpamplona.amethyst.ui.screen.loggedOff.signup.SignUpPage @Composable fun LoginOrSignupScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt deleted file mode 100644 index ac86217662..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ /dev/null @@ -1,810 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedOff - -import android.app.Activity -import android.content.Intent -import android.util.Log -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.util.Consumer -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.hashtags.Amethyst -import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.service.PackageUtils -import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.components.LoadingAnimation -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.Size0dp -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size40dp -import com.vitorpamplona.amethyst.ui.theme.Size50dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher -import com.vitorpamplona.quartz.nip55AndroidSigner.SignerType -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.UUID - -@Preview(device = "spec:width=2160px,height=2340px,dpi=440") -@Composable -fun LoginPage() { - val accountViewModel: AccountStateViewModel = viewModel() - - ThemeComparisonRow( - toPreview = { - LoginPage(accountViewModel, true) {} - }, - ) -} - -@Composable -fun LoginPage( - accountStateViewModel: AccountStateViewModel, - isFirstLogin: Boolean, - newAccountKey: String? = null, - onWantsToLogin: () -> Unit, -) { - val key = remember { mutableStateOf(TextFieldValue(newAccountKey ?: "")) } - var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } - var termsAcceptanceIsRequired by remember { mutableStateOf("") } - - val context = LocalContext.current - val torSettings = remember { mutableStateOf(TorSettings()) } - val isNFCOrQR = remember { mutableStateOf(false) } - val isTemporary = remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - var loginWithExternalSigner by remember { mutableStateOf(false) } - - var processingLogin by remember { mutableStateOf(false) } - - val password = remember { mutableStateOf(TextFieldValue("")) } - val needsPassword = - remember { - derivedStateOf { - key.value.text.startsWith("ncryptsec1") - } - } - - val passwordFocusRequester = remember { FocusRequester() } - - if (loginWithExternalSigner) { - PrepareExternalSignerReceiver { pubkey, packageName -> - key.value = TextFieldValue(pubkey) - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountStateViewModel.login( - key = key.value.text, - torSettings = torSettings.value, - transientAccount = isTemporary.value, - loginWithExternalSigner = true, - packageName = packageName, - ) { - errorMessage = stringRes(context, R.string.invalid_key) - } - } - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(Size20dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - imageVector = CustomHashTagIcons.Amethyst, - contentDescription = stringRes(R.string.app_logo), - modifier = Modifier.size(150.dp), - contentScale = ContentScale.Inside, - ) - - Spacer(modifier = Modifier.height(Size40dp)) - - KeyTextField( - value = key.value, - onValueChange = { value, isQr -> - key.value = value - if (isQr) { - isNFCOrQR.value = true - isTemporary.value = true - } - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, - ) { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login( - key = key.value.text, - password = password.value.text, - torSettings = torSettings.value, - transientAccount = isTemporary.value, - ) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - } - - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - if (needsPassword.value) { - PasswordField( - value = password.value, - onValueChange = { - password.value = it - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, - passwordFocusRequester, - ) { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - } - } - Spacer(modifier = Modifier.height(10.dp)) - - TorSettingsSetup( - torSettings = torSettings.value, - onCheckedChange = { - torSettings.value = it - }, - onError = { - scope.launch { - Toast - .makeText( - context, - it, - Toast.LENGTH_LONG, - ).show() - } - }, - ) - - if (isNFCOrQR.value) { - OfferTemporaryAccount( - checked = isTemporary.value, - onCheckedChange = { isTemporary.value = it }, - ) - } - - if (isFirstLogin) { - AcceptTerms( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, - ) - - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - - Spacer(modifier = Modifier.height(Size10dp)) - - Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - LoginButton( - enabled = acceptedTerms.value, - processingLogin = processingLogin, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - }, - ) - } - - if (PackageUtils.isExternalSignerInstalled(context)) { - Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) { - LoginWithAmberButton( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } else { - loginWithExternalSigner = true - } - }, - ) - } - } - - Spacer(modifier = Modifier.height(Size40dp)) - - Text(text = stringRes(R.string.don_t_have_an_account)) - - Spacer(modifier = Modifier.height(Size20dp)) - - Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - SignUpButton(onWantsToLogin) - } - } - - OpenURIIfNotLoggedIn { - key.value = TextFieldValue(it) - acceptedTerms.value = true - isNFCOrQR.value = true - isTemporary.value = true - if (it.startsWith("ncryptsec1")) { - delay(300) - passwordFocusRequester.requestFocus() - } - } -} - -@Composable -fun OfferTemporaryAccount( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, - ) - - Text(stringRes(R.string.temporary_account)) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PasswordField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - passwordFocusRequester: FocusRequester, - onGo: () -> Unit, -) { - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it)) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it)) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - - var showCharsPassword by remember { mutableStateOf(false) } - OutlinedTextField( - modifier = - Modifier - .focusRequester(passwordFocusRequester) - .onGloballyPositioned { coordinates -> - autofillNodePassword.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodePassword) - } else { - cancelAutofillForNode(autofillNodePassword) - } - } - }, - value = value, - onValueChange = onValueChange, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.ncryptsec_password), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsPassword = !showCharsPassword }) { - Icon( - imageVector = - if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsPassword) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } - } - }, - visualTransformation = - if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - onGo() - }, - ), - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun KeyTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit, - onLogin: () -> Unit, -) { - var dialogOpen by remember { mutableStateOf(false) } - - var showCharsKey by remember { mutableStateOf(false) } - - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it), false) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it), false) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - - OutlinedTextField( - modifier = - Modifier - .onGloballyPositioned { coordinates -> - autofillNodeKey.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodeKey) - } else { - cancelAutofillForNode(autofillNodeKey) - } - } - }, - value = value, - onValueChange = { onValueChange(it, false) }, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.nsec_npub_hex_private_key), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsKey = !showCharsKey }) { - Icon( - imageVector = - if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsKey) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } - } - }, - leadingIcon = { - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - onValueChange(TextFieldValue(it), true) - } - } - } - IconButton(onClick = { dialogOpen = true }) { - Icon( - painter = painterRes(R.drawable.ic_qrcode), - contentDescription = - stringRes( - R.string.login_with_qr_code, - ), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - visualTransformation = - if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - onLogin() - }, - ), - ) -} - -@Composable -private fun PrepareExternalSignerReceiver(onLogin: (pubkey: String, packageName: String) -> Unit) { - val scope = rememberCoroutineScope() - val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } - val id = remember { UUID.randomUUID().toString() } - - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - scope.launch(Dispatchers.Main) { - Toast - .makeText( - Amethyst.instance, - "Sign request rejected", - Toast.LENGTH_SHORT, - ).show() - } - } else { - result.data?.let { externalSignerLauncher.newResult(it) } - } - }, - ) - - val activity = getActivity() as MainActivity - - DisposableEffect(launcher, activity, externalSignerLauncher) { - externalSignerLauncher.registerLauncher( - launcher = { - try { - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - scope.launch(Dispatchers.Main) { - Toast - .makeText( - Amethyst.instance, - R.string.error_opening_external_signer, - Toast.LENGTH_SHORT, - ).show() - } - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - onDispose { externalSignerLauncher.clearLauncher() } - } - - LaunchedEffect(externalSignerLauncher) { - externalSignerLauncher.openSignerApp( - "", - SignerType.GET_PUBLIC_KEY, - "", - id, - ) { result -> - val split = result.split("-") - val pubkey = split.first() - val packageName = if (split.size > 1) split[1] else "" - - onLogin(pubkey, packageName) - } - } -} - -@Composable -private fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) { - val context = LocalContext.current - val activity = context.getActivity() - val scope = rememberCoroutineScope() - - var currentIntentNextPage by remember { - val uri = - activity.intent - ?.data - ?.toString() - ?.ifBlank { null } - - activity.intent.data = null - - mutableStateOf(uri) - } - - currentIntentNextPage?.let { intentNextPage -> - var nip19 by remember { - mutableStateOf( - Nip19Parser.tryParseAndClean(currentIntentNextPage), - ) - } - - LaunchedEffect(intentNextPage) { - if (nip19 != null) { - nip19?.let { - scope.launch { - onNewNIP19(it) - } - nip19 = null - } - } else { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage), - Toast.LENGTH_SHORT, - ).show() - } - } - - currentIntentNextPage = null - } - } - - DisposableEffect(activity) { - val consumer = - Consumer { intent -> - val uri = intent.data?.toString() - if (!uri.isNullOrBlank()) { - val newNip19 = Nip19Parser.tryParseAndClean(uri) - if (newNip19 != null) { - scope.launch { - onNewNIP19(newNip19) - } - } else { - scope.launch { - delay(1000) - Toast - .makeText( - context, - stringRes(context, R.string.invalid_nip19_uri_description, uri), - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } -} - -@Composable -fun SignUpButton(onClick: () -> Unit) { - OutlinedButton( - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.sign_up), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} - -@Composable -fun LoginWithAmberButton( - enabled: Boolean, - onClick: () -> Unit, -) { - Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(Size50dp), - ) { - Text( - text = stringRes(R.string.login_with_external_signer), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} - -@Composable -fun LoginButton( - enabled: Boolean, - processingLogin: Boolean, - onClick: () -> Unit, -) { - Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(Size50dp), - ) { - Row(modifier = Modifier.padding(horizontal = Size40dp)) { - if (processingLogin) { - LoadingAnimation() - Spacer(modifier = DoubleHorzSpacer) - } - Text(stringRes(R.string.login)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt index 43051007bb..9e3ca242b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.amethyst.ui.tor.TorType @Composable fun TorSettingsSetup( @@ -45,8 +44,6 @@ fun TorSettingsSetup( onError: (String) -> Unit, ) { var connectOrbotDialogOpen by remember { mutableStateOf(false) } - var activeTor by remember { mutableStateOf(false) } - val primary = MaterialTheme.colorScheme.primary Text( @@ -63,7 +60,6 @@ fun TorSettingsSetup( torSettings = torSettings, onClose = { connectOrbotDialogOpen = false }, onPost = { torSettings -> - activeTor = torSettings.torType != TorType.OFF connectOrbotDialogOpen = false onCheckedChange(torSettings) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt new file mode 100644 index 0000000000..d3b2d274a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import android.app.Activity +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.DefaultSignerPermissions +import com.vitorpamplona.amethyst.ui.theme.Size0dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.client.ExternalSignerLogin +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@Composable +fun ExternalSignerButton(loginViewModel: LoginViewModel) { + val scope = rememberCoroutineScope() + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + scope.launch { + val resultData = result.data + if (result.resultCode == Activity.RESULT_OK && resultData != null) { + val loginInfo = ExternalSignerLogin.parseResult(resultData) + if (loginInfo is SignerResult.RequestAddressed.Successful) { + loginViewModel.updateKey(TextFieldValue(loginInfo.result.pubkey), false) + loginViewModel.loginWithExternalSigner(loginInfo.result.packageName) + } + } else { + loginViewModel.errorManager.error(R.string.sign_request_rejected2) + } + } + } + + Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) { + LoginWithAmberButton( + enabled = loginViewModel.acceptedTerms, + onClick = { + if (!loginViewModel.acceptedTerms) { + loginViewModel.termsAcceptanceIsRequiredError = true + } else { + try { + launcher.launch(ExternalSignerLogin.createIntent(DefaultSignerPermissions)) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ExternalSigner", "Error opening Signer app", e) + loginViewModel.errorManager.error(R.string.error_opening_external_signer) + } + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt new file mode 100644 index 0000000000..d0e73a6a64 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun KeyTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit, + onLogin: () -> Unit, +) { + var dialogOpen by remember { mutableStateOf(false) } + + var showCharsKey by remember { mutableStateOf(false) } + + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it), false) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it), false) }, + ) + + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword + + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNodeKey.boundingBox = coordinates.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodeKey) + } else { + cancelAutofillForNode(autofillNodeKey) + } + } + }, + value = value, + onValueChange = { onValueChange(it, false) }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.nsec_npub_hex_private_key), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsKey = !showCharsKey }) { + Icon( + imageVector = + if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsKey) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + onValueChange(TextFieldValue(it), true) + } + } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterRes(R.drawable.ic_qrcode, 5), + contentDescription = stringRes(R.string.login_with_qr_code), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + visualTransformation = + if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + onLogin() + }, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButton.kt new file mode 100644 index 0000000000..642ece51a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButton.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size50dp + +@Composable +fun LoginButton( + enabled: Boolean, + processingLogin: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(Size50dp), + ) { + Row(modifier = Modifier.padding(horizontal = Size40dp)) { + if (processingLogin) { + LoadingAnimation() + Spacer(modifier = DoubleHorzSpacer) + } + Text(stringRes(R.string.login)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager.kt new file mode 100644 index 0000000000..9dc57b9d37 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class LoginErrorManager { + interface IErrorMsg + + class SingleErrorMsg( + val errorResId: Int, + ) : IErrorMsg + + class ParamsErrorMsg( + val errorResId: Int, + val params: Array, + ) : IErrorMsg + + var error by mutableStateOf(null) + + fun clearErrors() { + error = null + } + + fun error(resourceId: Int) { + error = SingleErrorMsg(resourceId) + } + + fun error( + resourceId: Int, + vararg params: String, + ) { + error = ParamsErrorMsg(resourceId, params) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt new file mode 100644 index 0000000000..8329b0b94c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -0,0 +1,362 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.Amethyst +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms +import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip55AndroidSigner.client.isExternalSignerInstalled +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Preview(device = "spec:width=2160px,height=2340px,dpi=440") +@Composable +fun LoginPagePreview() { + val accountViewModel: AccountStateViewModel = viewModel() + + ThemeComparisonRow( + toPreview = { + LoginPage(accountViewModel, true) {} + }, + ) +} + +@Composable +fun LoginPage( + accountStateViewModel: AccountStateViewModel, + isFirstLogin: Boolean, + newAccountKey: String? = null, + onWantsToLogin: () -> Unit, +) { + val loginViewModel: LoginViewModel = viewModel() + loginViewModel.init(accountStateViewModel) + + LaunchedEffect(Unit) { + loginViewModel.load(isFirstLogin, newAccountKey) + } + + LoginPage(loginViewModel, onWantsToLogin) +} + +@Composable +fun LoginPage( + loginViewModel: LoginViewModel, + onWantsToLogin: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(Size20dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + imageVector = CustomHashTagIcons.Amethyst, + contentDescription = stringRes(R.string.app_logo), + modifier = Modifier.size(150.dp), + contentScale = ContentScale.Inside, + ) + + Spacer(modifier = Modifier.height(Size40dp)) + + KeyTextField( + value = loginViewModel.key, + onValueChange = loginViewModel::updateKey, + onLogin = loginViewModel::login, + ) + + loginViewModel.errorManager.error?.let { error -> + when (error) { + is LoginErrorManager.SingleErrorMsg -> + Text( + text = stringRes(error.errorResId), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + is LoginErrorManager.ParamsErrorMsg -> + Text( + text = stringRes(error.errorResId, *error.params), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + else -> {} + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + PasswordField(loginViewModel) + + Spacer(modifier = Modifier.height(10.dp)) + + TorSettingsSetup( + torSettings = loginViewModel.torSettings, + onCheckedChange = loginViewModel::updateTorSettings, + onError = { + scope.launch { + Toast + .makeText( + context, + it, + Toast.LENGTH_LONG, + ).show() + } + }, + ) + + if (loginViewModel.offerTemporaryLogin) { + OfferTemporaryAccount( + checked = loginViewModel.isTemporary, + onCheckedChange = { loginViewModel.isTemporary = it }, + ) + } + + if (loginViewModel.isFirstLogin) { + AcceptTerms( + checked = loginViewModel.acceptedTerms, + onCheckedChange = loginViewModel::updateAcceptedTerms, + ) + + if (loginViewModel.termsAcceptanceIsRequiredError) { + Text( + text = stringRes(R.string.acceptance_of_terms_is_required), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Spacer(modifier = Modifier.height(Size10dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + LoginButton( + enabled = loginViewModel.acceptedTerms, + processingLogin = loginViewModel.processingLogin, + onClick = loginViewModel::login, + ) + } + + if (isExternalSignerInstalled(context)) { + ExternalSignerButton(loginViewModel) + } + + Spacer(modifier = Modifier.height(Size40dp)) + + Text(text = stringRes(R.string.don_t_have_an_account)) + + Spacer(modifier = Modifier.height(Size20dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + SignUpButton(onWantsToLogin) + } + } + + OpenURIIfNotLoggedIn { key -> + loginViewModel.updateKey(TextFieldValue(key), true) + loginViewModel.updateOfferTemporaryLogin(true) + } +} + +@Composable +fun OfferTemporaryAccount( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + + Text(stringRes(R.string.temporary_account)) + } +} + +@Composable +private fun PasswordField(loginViewModel: LoginViewModel) { + if (loginViewModel.needsPassword) { + val passwordFocusRequester = remember { FocusRequester() } + + PasswordField( + value = loginViewModel.password, + onValueChange = loginViewModel::updatePassword, + passwordFocusRequester = passwordFocusRequester, + onGo = loginViewModel::login, + ) + + LaunchedEffect(Unit) { + delay(300) + passwordFocusRequester.requestFocus() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PasswordField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + passwordFocusRequester: FocusRequester, + onGo: () -> Unit, +) { + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it)) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it)) }, + ) + + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword + + var showCharsPassword by remember { mutableStateOf(false) } + OutlinedTextField( + modifier = + Modifier + .focusRequester(passwordFocusRequester) + .onGloballyPositioned { coordinates -> + autofillNodePassword.boundingBox = coordinates.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodePassword) + } else { + cancelAutofillForNode(autofillNodePassword) + } + } + }, + value = value, + onValueChange = onValueChange, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + onGo() + }, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt new file mode 100644 index 0000000000..eab3f7b756 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.tor.TorSettings + +class LoginViewModel : ViewModel() { + lateinit var accountStateViewModel: AccountStateViewModel + + val errorManager = LoginErrorManager() + + var key by mutableStateOf(TextFieldValue("")) + var acceptedTerms by mutableStateOf(false) + var termsAcceptanceIsRequiredError by mutableStateOf(false) + + var torSettings by mutableStateOf(TorSettings()) + var offerTemporaryLogin by mutableStateOf(false) + var isTemporary by mutableStateOf(false) + + var processingLogin by mutableStateOf(false) + + var password by mutableStateOf(TextFieldValue("")) + val needsPassword by derivedStateOf { + key.text.startsWith("ncryptsec1") + } + + var isFirstLogin by mutableStateOf(false) + + fun init(accountStateViewModel: AccountStateViewModel) { + this.accountStateViewModel = accountStateViewModel + } + + fun load( + isFirstLogin: Boolean, + newAccountKey: String?, + ) { + clear() + this.isFirstLogin = isFirstLogin + acceptedTerms = !isFirstLogin + if (newAccountKey != null) { + key = TextFieldValue(newAccountKey) + } + } + + fun clear() { + key = TextFieldValue("") + password = TextFieldValue("") + + errorManager.clearErrors() + acceptedTerms = false + processingLogin = false + isTemporary = false + offerTemporaryLogin = false + torSettings = TorSettings() + isFirstLogin = false + } + + fun updateKey( + value: TextFieldValue, + throughQR: Boolean, + ) { + key = value + if (throughQR) { + offerTemporaryLogin = true + isTemporary = true + } + errorManager.clearErrors() + } + + fun updatePassword(newPassword: TextFieldValue) { + password = newPassword + errorManager.clearErrors() + } + + fun updateTorSettings(newTorSettings: TorSettings) { + torSettings = newTorSettings + } + + fun updateAcceptedTerms(newAcceptedTerms: Boolean) { + acceptedTerms = newAcceptedTerms + if (newAcceptedTerms) { + termsAcceptanceIsRequiredError = false + } + + errorManager.clearErrors() + } + + fun updateOfferTemporaryLogin(tempLogin: Boolean) { + offerTemporaryLogin = tempLogin + } + + fun checkCanLogin(): Boolean { + if (!acceptedTerms) { + termsAcceptanceIsRequiredError = true + return false + } + + if (key.text.isBlank()) { + errorManager.error(R.string.key_is_required) + return false + } + + if (needsPassword && password.text.isBlank()) { + errorManager.error(R.string.password_is_required) + return false + } + + return true + } + + fun login() { + if (checkCanLogin()) { + processingLogin = true + accountStateViewModel.login( + key = key.text, + password = password.text, + torSettings = torSettings, + transientAccount = isTemporary, + ) { + processingLogin = false + if (it != null) { + errorManager.error(R.string.invalid_key_with_message, it) + } else { + errorManager.error(R.string.invalid_key) + } + } + } + } + + fun loginWithExternalSigner(packageName: String) { + if (checkCanLogin()) { + processingLogin = true + accountStateViewModel.login( + key = key.text, + torSettings = torSettings, + transientAccount = isTemporary, + loginWithExternalSigner = true, + packageName = packageName, + ) { + processingLogin = false + errorManager.error(R.string.sign_request_rejected_description) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginWithAmberButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginWithAmberButton.kt new file mode 100644 index 0000000000..d293ac7606 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginWithAmberButton.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size50dp + +@Composable +fun LoginWithAmberButton( + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(Size50dp), + ) { + Text( + text = stringRes(R.string.login_with_external_signer), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt new file mode 100644 index 0000000000..856306819c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import android.content.Intent +import android.widget.Toast +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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.util.Consumer +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) { + val context = LocalContext.current + val activity = context.getActivity() + val scope = rememberCoroutineScope() + + var currentIntentNextPage by remember { + val uri = + activity.intent + ?.data + ?.toString() + ?.ifBlank { null } + + activity.intent.data = null + + mutableStateOf(uri) + } + + currentIntentNextPage?.let { intentNextPage -> + var nip19 by remember { + mutableStateOf( + Nip19Parser.tryParseAndClean(currentIntentNextPage), + ) + } + + LaunchedEffect(intentNextPage) { + if (nip19 != null) { + nip19?.let { + scope.launch { + onNewNIP19(it) + } + nip19 = null + } + } else { + scope.launch { + Toast + .makeText( + context, + stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage), + Toast.LENGTH_SHORT, + ).show() + } + } + + currentIntentNextPage = null + } + } + + DisposableEffect(activity) { + val consumer = + Consumer { intent -> + val uri = intent.data?.toString() + if (!uri.isNullOrBlank()) { + val newNip19 = Nip19Parser.tryParseAndClean(uri) + if (newNip19 != null) { + scope.launch { + onNewNIP19(newNip19) + } + } else { + scope.launch { + delay(1000) + Toast + .makeText( + context, + stringRes(context, R.string.invalid_nip19_uri_description, uri), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButton.kt new file mode 100644 index 0000000000..140d5865e5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButton.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp + +@Composable +fun SignUpButton(onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.sign_up), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/LoginButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/LoginButton.kt new file mode 100644 index 0000000000..7991bf9ba5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/LoginButton.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp + +@Composable +fun LoginButton(onWantsToLogin: () -> Unit) { + OutlinedButton( + onClick = onWantsToLogin, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.login), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpButton.kt new file mode 100644 index 0000000000..64076886ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpButton.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp + +@Composable +fun SignUpButton( + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.create_account), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index d2a4478b2a..cbb8f530f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -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.loggedOff +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup import android.widget.Toast import androidx.compose.foundation.Image @@ -32,28 +32,20 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -61,19 +53,20 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms +import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.tor.TorSettings import kotlinx.coroutines.launch @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable -fun SignUpPage() { +fun SignUpPagePreview() { val accountViewModel: AccountStateViewModel = viewModel() ThemeComparisonRow( @@ -88,13 +81,18 @@ fun SignUpPage( accountStateViewModel: AccountStateViewModel, onWantsToLogin: () -> Unit, ) { - val displayName = remember { mutableStateOf(TextFieldValue("")) } - var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(false) } - var termsAcceptanceIsRequired by remember { mutableStateOf("") } + val signUpViewModel: SignUpViewModel = viewModel() + signUpViewModel.init(accountStateViewModel) + SignUpPage(signUpViewModel, onWantsToLogin) +} + +@Composable +fun SignUpPage( + signUpViewModel: SignUpViewModel, + onWantsToLogin: () -> Unit, +) { val context = LocalContext.current - val torSettings = remember { mutableStateOf(TorSettings()) } val scope = rememberCoroutineScope() Column( @@ -125,8 +123,8 @@ fun SignUpPage( Spacer(modifier = Modifier.height(Size20dp)) OutlinedTextField( - value = displayName.value, - onValueChange = { displayName.value = it }, + value = signUpViewModel.displayName, + onValueChange = signUpViewModel::updateDisplayName, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, @@ -142,49 +140,47 @@ fun SignUpPage( keyboardActions = KeyboardActions( onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (displayName.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.name_is_required) - } - - if (acceptedTerms.value && displayName.value.text.isNotBlank()) { - accountStateViewModel.newKey(torSettings.value, displayName.value.text) - } + signUpViewModel.signup() }, ), ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) + + signUpViewModel.errorManager.error?.let { error -> + when (error) { + is LoginErrorManager.SingleErrorMsg -> + Text( + text = stringRes(error.errorResId), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + is LoginErrorManager.ParamsErrorMsg -> + Text( + text = stringRes(error.errorResId, *error.params), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + else -> {} + } } Spacer(modifier = Modifier.height(10.dp)) AcceptTerms( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, + checked = signUpViewModel.acceptedTerms, + onCheckedChange = signUpViewModel::updateAcceptedTerms, ) - if (termsAcceptanceIsRequired.isNotBlank()) { + if (signUpViewModel.termsAcceptanceIsRequiredError) { Text( - text = termsAcceptanceIsRequired, + text = stringRes(R.string.acceptance_of_terms_is_required), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) } TorSettingsSetup( - torSettings = torSettings.value, - onCheckedChange = { - torSettings.value = it - }, + torSettings = signUpViewModel.torSettings, + onCheckedChange = signUpViewModel::updateTorSettings, onError = { scope.launch { Toast @@ -201,20 +197,8 @@ fun SignUpPage( Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { SignUpButton( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (displayName.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.name_is_required) - } - - if (acceptedTerms.value && displayName.value.text.isNotBlank()) { - accountStateViewModel.newKey(torSettings.value, displayName.value.text) - } - }, + enabled = signUpViewModel.acceptedTerms, + onClick = signUpViewModel::signup, ) } @@ -229,35 +213,3 @@ fun SignUpPage( } } } - -@Composable -fun LoginButton(onWantsToLogin: () -> Unit) { - OutlinedButton( - onClick = onWantsToLogin, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.login), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} - -@Composable -fun SignUpButton( - enabled: Boolean, - onClick: () -> Unit, -) { - Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.create_account), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt new file mode 100644 index 0000000000..39f8500a35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager +import com.vitorpamplona.amethyst.ui.tor.TorSettings + +class SignUpViewModel : ViewModel() { + lateinit var accountStateViewModel: AccountStateViewModel + + val errorManager = LoginErrorManager() + + var displayName by mutableStateOf(TextFieldValue("")) + + var acceptedTerms by mutableStateOf(false) + var termsAcceptanceIsRequiredError by mutableStateOf(false) + + var torSettings by mutableStateOf(TorSettings()) + + fun init(accountStateViewModel: AccountStateViewModel) { + this.accountStateViewModel = accountStateViewModel + } + + fun updateDisplayName(value: TextFieldValue) { + displayName = value + errorManager.clearErrors() + } + + fun updateTorSettings(newTorSettings: TorSettings) { + torSettings = newTorSettings + } + + fun updateAcceptedTerms(newAcceptedTerms: Boolean) { + acceptedTerms = newAcceptedTerms + if (newAcceptedTerms) { + termsAcceptanceIsRequiredError = false + } + + errorManager.clearErrors() + } + + fun checkCanSignup(): Boolean { + if (!acceptedTerms) { + termsAcceptanceIsRequiredError = true + return false + } + + if (displayName.text.isBlank()) { + errorManager.error(R.string.name_is_required) + return false + } + + return true + } + + fun signup() { + if (checkCanSignup()) { + accountStateViewModel.newKey(torSettings, displayName.text) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 66990bc7b1..f9acf35efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -52,13 +52,20 @@ val Nip05EmailColorLight = Color(0xFFa770f3) val DarkerGreen = Color.Green.copy(alpha = 0.32f) -val WarningColor = Color(0xFFC62828) +val LightRedColor = Color(0xFFC62828) +val LighterRedColor = Color(0xFFFF0E0E) val RelayIconFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) }) val LightWarningColor = Color(0xFFffcc00) val DarkWarningColor = Color(0xFFF8DE22) +val LightRedColorOnSecondSurface = Color(0xFFC62828) +val DarkRedColorOnSecondSurface = Color(0xFFF34747) + +val LightWarningColorOnSecondSurface = Color(0xFFC09B14) +val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) + val LightAllGoodColor = Color(0xFF339900) val DarkAllGoodColor = Color(0xFF99cc33) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index b70d522d11..272519c0c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -34,6 +35,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Shapes @@ -85,8 +87,6 @@ val Height100Modifier = Modifier.height(100.dp) val HalfDoubleVertSpacer = Modifier.height(7.dp) -val TopBarSize = 50.dp - 64.dp - val Size0dp = 0.dp val Size5dp = 5.dp val Size6dp = 6.dp @@ -218,6 +218,7 @@ val EditFieldModifier = val EditFieldTrailingIconModifier = Modifier.padding(start = 5.dp, end = 0.dp) val ZeroPadding = PaddingValues(0.dp) +val HalfFeedPadding = PaddingValues(5.dp) val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp) val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) @@ -284,10 +285,8 @@ val inlinePlaceholder = placeholderVerticalAlign = PlaceholderVerticalAlign.Center, ) -val incognitoIconModifier = - Modifier - .padding(top = 1.dp) - .size(14.dp) +val IncognitoIconModifier = Modifier.padding(top = 1.dp).size(14.dp) +val IncognitoIconButtonModifier = Modifier.padding(top = 2.dp).size(20.dp) val hashVerifierMark = Modifier.width(40.dp).height(40.dp).padding(10.dp) @@ -333,3 +332,18 @@ val LargeRelayIconModifier = Modifier .size(Size55dp) .clip(shape = CircleShape) + +val FollowSetImageModifier = + Modifier + .fillMaxWidth() + .clip(QuoteBorder) + .aspectRatio(ratio = 21f / 9f) + +val SimpleImage75Modifier = Modifier.size(Size75dp).clip(QuoteBorder) +val SimpleImage35Modifier = Modifier.size(Size34dp).clip(shape = CircleShape) + +val SimpleImageBorder = Modifier.fillMaxSize().clip(QuoteBorder) + +val SimpleHeaderImage = Modifier.fillMaxWidth().heightIn(max = 200.dp) + +val BadgePictureModifier = Modifier.size(35.dp).clip(shape = CutCornerShape(20)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 1970de0a03..2258053c4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -167,7 +167,7 @@ val DarkReplyBorderModifier = val LightReplyBorderModifier = Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .padding(top = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) @@ -391,9 +391,15 @@ val ColorScheme.overPictureBackground: Color val ColorScheme.bitcoinColor: Color get() = if (isLight) BitcoinLight else BitcoinDark +val ColorScheme.redColorOnSecondSurface: Color + get() = if (isLight) LightRedColorOnSecondSurface else DarkRedColorOnSecondSurface + val ColorScheme.warningColor: Color get() = if (isLight) LightWarningColor else DarkWarningColor +val ColorScheme.warningColorOnSecondSurface: Color + get() = if (isLight) LightWarningColorOnSecondSurface else DarkWarningColorOnSecondSurface + val ColorScheme.allGoodColor: Color get() = if (isLight) LightAllGoodColor else DarkAllGoodColor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index d87c012a48..fc370db073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -23,22 +23,19 @@ package com.vitorpamplona.amethyst.ui.tor import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface +import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -48,8 +45,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes @@ -74,14 +71,13 @@ fun ConnectTorDialog( decorFitsSystemWindows = false, ), ) { - Surface { - TorDialogContents( - torSettings, - onClose, - onPost, - onError, - ) - } + SetDialogToEdgeToEdge() + TorDialogContents( + torSettings, + onClose, + onPost, + onError, + ) } } @@ -121,24 +117,12 @@ fun TorDialogContents( onPost: (torSettings: TorSettings) -> Unit, onError: (String) -> Unit, ) { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll( - rememberScrollState(), - ).padding(10.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - CloseButton(onPress = { onClose() }) - + Scaffold( + topBar = { val toastMessage = stringRes(R.string.invalid_port_number) - - SaveButton( + SavingTopBar( + titleRes = R.string.privacy_options, + onCancel = onClose, onPost = { try { onPost(dialogViewModel.save()) @@ -147,11 +131,19 @@ fun TorDialogContents( onError(toastMessage) } }, - isActive = true, ) + }, + ) { + Column( + Modifier + .padding(it) + .fillMaxSize() + .verticalScroll( + rememberScrollState(), + ).padding(horizontal = 10.dp), + ) { + PrivacySettingsBody(dialogViewModel) } - - PrivacySettingsBody(dialogViewModel) } } diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 2e780a1344..48dcf6594e 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -1,5 +1,5 @@ - + Point to the QR Code Show QR Profile Image diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 107dafa9f3..b92786e71f 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -1,5 +1,5 @@ - + Profila attēls Jūsu profila attēls 👀 diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 25197aa216..4e434e5ce6 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -1,5 +1,5 @@ - + Покажите на КР код Прикажи КР слика профила diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 35621aa3a6..b404a3f538 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -1,5 +1,5 @@ - + QR Code\'a yönlendir QR Göster Profil Resmi diff --git a/amethyst/src/main/res/values-tr/strings.xml b/amethyst/src/main/res/values-tr/strings.xml index 35621aa3a6..b404a3f538 100644 --- a/amethyst/src/main/res/values-tr/strings.xml +++ b/amethyst/src/main/res/values-tr/strings.xml @@ -1,5 +1,5 @@ - + QR Code\'a yönlendir QR Göster Profil Resmi diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index cc4646375a..49538bb471 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -1,5 +1,5 @@ - + Point to the QR Code QR kodni ko‘rsatish Profil rasmini diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index d374dcabd9..2392d4a794 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -1,5 +1,5 @@ - + Hình ảnh nhóm Hành vi trái pháp luật Không xác định diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 66a570486b..3ea04e700d 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,2 +1,2 @@ - + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2d9b6cdb22..fb6d27b6fb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -55,6 +55,16 @@ You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow You are using a public key and public keys are read-only. Login with a Private key to be able to hide a word or sentence You are using a public key and public keys are read-only. Login with a Private key to be able to show a word or sentence + You are using a public key and public keys are read-only. Login with a Private key to be able to change settings + You are using a public key and public keys are read-only. Login with a Private key to be able to upload + + You are using a public key and public keys are read-only. Login with a Private key to be able to sign for events + + Unauthorized Decryption + The signer did not authorize a decryption that is required to make this operation. Activate NIP-44 decryptions in your signer app and try again + + Signer not found + Was the Signer app uninstalled? Check if the signer is installed and has this account. Log off and Log in again of the signer app has changed. Zaps @@ -107,6 +117,7 @@ My Awesome Group Picture Url Description + Description not found "About us.. " What\'s on your mind? Write a message… @@ -148,6 +159,10 @@ Video saved to the phone\'s video gallery Failed to save the video Upload Image + Take a picture + Record a message + Record a message + Click and hold to record a message Uploading… User does not have a lightning address set up to receive sats "reply here.. " @@ -244,6 +259,9 @@ Insert between 1–3 relays that host this group. Nostr clients use this setting to know where to download messages from and send your messages to. + Paid relay + Forces Tor when connecting + posts received Remove sats @@ -293,6 +311,7 @@ Error "Created by %1$s" "Badge award image for %1$s" + "Badge award image You Received a new Badge Award Badge award granted to Copied note text to clipboard @@ -480,6 +499,7 @@ Follow List All Follows + Follows via Proxy Around Me Global Mute List @@ -832,6 +852,7 @@ Wallet %1$s Error opening signer app The signer app could not be found. Check if the app hasn\'t been uninstalled + Sign request rejected Signer Application Rejected Make sure the signer application has authorized this transaction @@ -1061,6 +1082,11 @@ Insert between 1–3 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content. General Relays Amethyst uses these relays to download posts for you. + + + Connected Relays + Current list of relays being used + Recommended Relays Add the following relays to your General Relays list in order to receive posts from the listed users. Search Relays @@ -1068,6 +1094,26 @@ Local Relays List of relays that are running in this device. + Trusted Relays + Trusted Relays + Relays you trust to not need a Tor connection for + + Proxy Relays + Proxy Relays + Aggregator relays the app must use to download your feeds from, like filter.nostr.wine. This replaces the outbox model and will make the app connect to only the relays in your lists. + + Broadcast Relays + Broadcast Relays + Relays that specialize in pushing your notes to all of the other relays, like sendit.nosflare.com. Amethyst will add this relay to all new events made by you + + Indexer Relays + Indexer Relays + Relays that specialize in hosting everyone\'s metadata and relay lists, like purplepag.es. Amethyst will use these relays to find users that are not in your lists. + + Blocked Relays + Blocked Relays + Amethyst will never connect to these relays + Zap the Devs! Your donation helps us make a difference. Every sat counts! Donate Now @@ -1173,6 +1219,7 @@ Log off on device lock Private Message + Public Message Chat Relay The relay that all users of this chat connect to diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 4592190bb4..03b7d5f790 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -56,7 +56,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.TranslationsCache import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -244,10 +244,8 @@ private fun TranslationMessage( } }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.prefer(source, target, source) - langSettingsPopupExpanded = false - } + accountViewModel.prefer(source, target, source) + langSettingsPopupExpanded = false }, ) DropdownMenuItem( @@ -277,7 +275,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.account.prefer(source, target, target) + accountViewModel.prefer(source, target, target) langSettingsPopupExpanded = false } }, @@ -311,10 +309,8 @@ private fun TranslationMessage( } }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.updateTranslateTo(lang) - langSettingsPopupExpanded = false - } + langSettingsPopupExpanded = false + accountViewModel.updateTranslateTo(lang) }, ) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index 18d732481f..8ec1602d0c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -23,8 +23,9 @@ package com.vitorpamplona.amethyst import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -43,9 +44,9 @@ class NewMessageTaggerKeyParseTest { com.vitorpamplona.amethyst.model .Note(hex) - override suspend fun checkGetOrCreateAddressableNote(hex: String) = + override suspend fun getOrCreateAddressableNote(address: Address) = com.vitorpamplona.amethyst.model - .Note(hex) + .AddressableNote(address) } @Test @@ -53,10 +54,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(null, result?.restOfWord) } @@ -79,10 +80,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -105,10 +106,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -133,10 +134,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", ) - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -163,10 +164,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", ) - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt deleted file mode 100644 index 47e6686623..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -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.model.nip30CustomEmojis.EmojiPackState.EmojiMedia -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkAll -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test - -@ExperimentalCoroutinesApi -class NewPostViewModelTest { - @MockK - lateinit var accountViewModel: AccountViewModel - - @MockK(relaxed = true) - lateinit var replyingTo: Note - - private lateinit var shortNoteViewModelUnderTest: ShortNotePostViewModel - - @Before - fun setup() { - mockkObject(LocalCache) - every { LocalCache.getOrCreateUser(any()) } returns mockk() - shortNoteViewModelUnderTest = ShortNotePostViewModel() - MockKAnnotations.init(this) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `test load with mentions`() = - runTest { - // Arrange: Setup with non empty mentions - every { accountViewModel.account } returns mockk() - - val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns listOf(PTag("user1"), PTag("user2")) - every { replyingTo.event } returns textNoteEvent - - every { accountViewModel.userProfile() } returns mockk(relaxed = true) - every { accountViewModel.account.userProfile() } returns mockk(relaxed = true) - every { accountViewModel.account.emoji.myEmojis } returns mockk>>(relaxed = true) - - // Act: Call load with mentions - shortNoteViewModelUnderTest.init(accountViewModel) - shortNoteViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) - - // Assert - // Two mentions should call LocalCache.getOrCreateUser twice - verify(exactly = 2) { LocalCache.getOrCreateUser(any()) } - } - - @Test - fun `test load with zero mentions`() = - runTest { - every { accountViewModel.account } returns mockk() - - // Arrange: Setup Note with zero mentions - val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns emptyList() - every { replyingTo.event } returns textNoteEvent - - every { accountViewModel.userProfile() } returns mockk(relaxed = true) - - // Act: Call load with empty mentions - shortNoteViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) - - // Assert - // With no mentions LocalCache.getOrCreateUser should not be called - verify(exactly = 0) { LocalCache.getOrCreateUser(any()) } - } - - @Test - fun `test load with empty mentions`() = - runTest { - every { accountViewModel.account } returns mockk() - - // Arrange: Setup empty mentions - val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns emptyList() - every { replyingTo.event } returns textNoteEvent - - every { accountViewModel.userProfile() } returns mockk(relaxed = true) - - // Act: Call load with empty mentions - shortNoteViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) - - // Assert - // Verify LocalCache.getOrCreateUser(it) is not called with empty hex, it will crash the app - verify(exactly = 0) { LocalCache.getOrCreateUser(any()) } - } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt index 3716170c4e..5b07e82f2a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt @@ -81,7 +81,7 @@ class MediaCompressorTest { ) // Verify - verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) } } @@ -102,7 +102,7 @@ class MediaCompressorTest { ) // Verify - verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) } } @@ -115,7 +115,7 @@ class MediaCompressorTest { val uri = mockk() val contentType = "video" - every { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } returns Unit + every { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } returns Unit // Execution MediaCompressor().compress( @@ -126,7 +126,7 @@ class MediaCompressorTest { ) // Verify - verify(exactly = 1) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 1) { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } } @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt new file mode 100644 index 0000000000..3219cde5ba --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal + +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class SupportedContentTest { + @Test + fun acceptableUrl() { + val supportedExtensions = setOf(".mp4", ".webm", ".jpg", ".png") + val mimeTypes = setOf("video/mp4", "video/webm", "image/jpeg", "image/png") + val blockedUrls = listOf("youtube.com", "youtu.be") + + val testVideoUrl = "https://example.com/video.mp4" + val blockedUrl = "https://youtube.com/watch?v=example" + val urlWithQuery = "https://example.com/media.jpg?param=1" + val urlWithFragment = "https://example.com/data.png#section" + + val contentSupport = SupportedContent(blockedUrls, mimeTypes, supportedExtensions) + + // Valid scenarios + assertTrue(contentSupport.acceptableUrl(testVideoUrl, "video/mp4")) // Valid extension and mime + assertTrue(contentSupport.acceptableUrl(urlWithQuery, "image/jpeg")) // Valid query param extension + assertTrue(contentSupport.acceptableUrl(urlWithFragment, "image/png")) // Valid fragment extension + + assertTrue(contentSupport.acceptableUrl(testVideoUrl, null)) + assertTrue(contentSupport.acceptableUrl(urlWithQuery, null)) + assertTrue(contentSupport.acceptableUrl(urlWithFragment, null)) + + // Blocked URL scenarios + assertTrue(!contentSupport.acceptableUrl(blockedUrl, null)) // Blocked URL + + // Invalid scenarios + assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", "application/docx")) // Unsupported extension/mime + assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", null)) // Unsupported extension/mime + } +} diff --git a/ammolite/build.gradle b/ammolite/build.gradle index ecd515719d..ec74b88080 100644 --- a/ammolite/build.gradle +++ b/ammolite/build.gradle @@ -6,12 +6,12 @@ plugins { } android { - namespace 'com.vitorpamplona.ammolite' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.ammolite' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -23,12 +23,12 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled = true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } create("benchmark") { initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } compileOptions { diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt deleted file mode 100644 index 720a302c7b..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays - -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter - -object Constants { - val activeTypesFollows = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) - val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) - val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - val activeTypesSearch = setOf(FeedType.SEARCH) - - val defaultRelays = - arrayOf( - // Free relays for only DMs, Chats and Follows due to the amount of spam - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.bg"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.oxtr.dev"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.fmt.wiz.biz"), read = true, write = false, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.damus.io"), read = true, write = true, feedTypes = activeTypesFollows), - // Global - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats), - // Paid relays - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostrelites.org"), read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats), - // Supporting NIP-50 - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.noswhere.com"), read = true, write = false, feedTypes = activeTypesSearch), - ) - - val defaultSearchRelaySet = - setOf( - RelayUrlFormatter.normalize("wss://relay.nostr.band"), - RelayUrlFormatter.normalize("wss://nostr.wine"), - RelayUrlFormatter.normalize("wss://relay.noswhere.com"), - ) -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt deleted file mode 100644 index 14b05d3edf..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt +++ /dev/null @@ -1,486 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays - -import android.util.Log -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * The Nostr Client manages a relay pool - */ -class NostrClient( - private val websocketBuilder: WebsocketBuilderFactory, -) : RelayPool.Listener { - private val relayPool: RelayPool = RelayPool() - private val activeSubscriptions: MutableSubscriptionCache = MutableSubscriptionCache() - private var listeners = setOf() - - fun buildRelay(it: RelaySetupInfoToConnect): Relay = Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes, websocketBuilder, activeSubscriptions) - - fun getRelay(url: String): Relay? = relayPool.getRelay(url) - - // Reconnects all relays that may have disconnected - fun reconnect() = relayPool.requestAndWatch() - - @Synchronized - fun reconnect( - relays: Array?, - onlyIfChanged: Boolean = false, - ) { - Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.forceProxy + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}") - checkNotInMainThread() - - if (onlyIfChanged) { - if (!isSameRelaySetConfig(relays)) { - if (this.relayPool.availableRelays() > 0) { - relayPool.disconnect() - relayPool.unregister(this) - relayPool.unloadRelays() - } - - if (relays != null) { - relayPool.register(this) - relayPool.loadRelays(relays.map(::buildRelay)) - relayPool.requestAndWatch() - } - } else { - // Reconnects all relays that may have disconnected - relayPool.requestAndWatch() - } - } else { - if (this.relayPool.availableRelays() > 0) { - relayPool.disconnect() - relayPool.unregister(this) - relayPool.unloadRelays() - } - - if (relays != null) { - relayPool.register(this) - relayPool.loadRelays(relays.map(::buildRelay)) - relayPool.requestAndWatch() - } - } - } - - fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { - if (relayPool.availableRelays() != newRelayConfig?.size) return false - - newRelayConfig.forEach { - val relay = relayPool.getRelay(it.url) ?: return false - if (!relay.isSameRelayConfig(it)) return false - } - - return true - } - - fun sendFilter( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() - - activeSubscriptions.add(subscriptionId, filters) - relayPool.sendFilter(subscriptionId, filters) - } - - fun sendFilterAndStopOnFirstResponse( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - onResponse: (Event) -> Unit, - ) { - checkNotInMainThread() - - subscribe( - object : Listener { - override fun onEvent( - event: Event, - subId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - if (subId == subscriptionId) { - unsubscribe(this) - close(subscriptionId) - - onResponse(event) - } - } - }, - ) - - activeSubscriptions.add(subscriptionId, filters) - relayPool.sendFilter(subscriptionId, filters) - } - - @OptIn(DelicateCoroutinesApi::class) - suspend fun sendAndWaitForResponse( - signedEvent: Event, - relay: String? = null, - forceProxy: Boolean = false, - feedTypes: Set? = null, - relayList: List? = null, - onDone: (() -> Unit)? = null, - additionalListener: Listener? = null, - timeoutInSeconds: Long = 15, - onAuth: ((Relay, String) -> Unit)? = null, - ): Boolean { - checkNotInMainThread() - - val size = if (relay != null) 1 else relayList?.size ?: relayPool.availableRelays() - val latch = CountDownLatch(size) - val relayErrors = mutableMapOf() - var result = false - - Log.d("sendAndWaitForResponse", "Waiting for $size responses") - - val subscription = - object : Listener { - override fun onAuth( - relay: Relay, - challenge: String, - ) { - onAuth?.let { - it(relay, challenge) - } - Log.d("sendAndWaitForResponse", "onAuth Auth from relay ${relay.url} count: ${latch.count} challenge: $challenge") - } - - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - relayErrors[relay.url]?.let { - latch.countDown() - } - Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} count: ${latch.count} error: $error") - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - latch.countDown() - Log.d("sendAndWaitForResponse", "onEOSE relay ${relay.url} count: ${latch.count}") - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) { - if (type == RelayState.DISCONNECTED) { - latch.countDown() - } - if (type == RelayState.CONNECTED) { - Log.d("sendAndWaitForResponse", "${type.name} Sending event to relay ${relay.url} count: ${latch.count}") - relay.send(signedEvent) - } - Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url} count: ${latch.count}") - } - - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - if (eventId == signedEvent.id) { - if (success) { - result = true - } - latch.countDown() - Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} count: ${latch.count} message $message success $success") - } - } - } - - subscribe(subscription) - additionalListener?.let { subscribe(it) } - - val job = - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - if (relayList != null) { - send(signedEvent, relayList) - } else if (relay == null) { - send(signedEvent) - } else { - sendSingle(signedEvent, RelaySetupInfoToConnect(relay, forceProxy, true, true, feedTypes ?: emptySet()), onDone ?: {}) - } - } - job.join() - - runBlocking { - latch.await(timeoutInSeconds, TimeUnit.SECONDS) - } - Log.d("sendAndWaitForResponse", "countdown finished") - unsubscribe(subscription) - additionalListener?.let { unsubscribe(it) } - return result - } - - fun getAll(): List = relayPool.getAll() - - fun sendFilterOnlyIfDisconnected( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() - - activeSubscriptions.add(subscriptionId, filters) - relayPool.connectAndSendFiltersIfDisconnected() - } - - fun sendIfExists( - signedEvent: Event, - connectedRelay: Relay, - ) { - checkNotInMainThread() - - relayPool.getRelays(connectedRelay.url).forEach { - it.send(signedEvent) - } - } - - fun sendSingle( - signedEvent: Event, - relayTemplate: RelaySetupInfoToConnect, - onDone: (() -> Unit), - ) { - checkNotInMainThread() - - relayPool.runCreatingIfNeeded(buildRelay(relayTemplate), onDone = onDone) { - it.send(signedEvent) - } - } - - fun send(signedEvent: Event) { - checkNotInMainThread() - relayPool.send(signedEvent) - } - - fun send( - signedEvent: Event, - relayList: List, - ) { - checkNotInMainThread() - - relayPool.sendToSelectedRelays(relayList, signedEvent) - } - - fun sendPrivately( - signedEvent: Event, - relayList: List, - ) { - checkNotInMainThread() - - relayList.forEach { relayTemplate -> - relayPool.runCreatingIfNeeded(buildRelay(relayTemplate)) { - it.sendOverride(signedEvent) - } - } - } - - fun close(subscriptionId: String) { - relayPool.close(subscriptionId) - activeSubscriptions.remove(subscriptionId) - } - - fun isActive(subscriptionId: String): Boolean = activeSubscriptions.isActive(subscriptionId) - - @OptIn(DelicateCoroutinesApi::class) - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onEvent(event, subscriptionId, relay, arrivalTime, afterEOSE) } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - listeners.forEach { it.onEOSE(relay, subscriptionId, arrivalTime) } - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) { - listeners.forEach { it.onRelayStateChange(type, relay) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onAuth( - relay: Relay, - challenge: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onAuth(relay, challenge) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onNotify( - relay: Relay, - description: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onNotify(relay, description) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) { - listeners.forEach { it.onSend(relay, msg, success) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onBeforeSend( - relay: Relay, - event: Event, - ) { - listeners.forEach { it.onBeforeSend(relay, event) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - } - - fun subscribe(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun isSubscribed(listener: Listener): Boolean = listeners.contains(listener) - - fun unsubscribe(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun allSubscriptions(): Map> = activeSubscriptions.allSubscriptions() - - fun getSubscriptionFilters(subId: String): List = activeSubscriptions.getSubscriptionFilters(subId) - - fun getSubscriptionFiltersOrNull(subId: String): List? = activeSubscriptions.getSubscriptionFiltersOrNull(subId) - - fun connectedRelays() = relayPool.connectedRelays() - - fun relayStatusFlow() = relayPool.statusFlow - - interface Listener { - /** A new message was received */ - open fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) = Unit - - /** Connected to or disconnected from a relay */ - open fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) = Unit - - /** Connected to or disconnected from a relay */ - open fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) = Unit - - /** When an relay saves or rejects a new event. */ - open fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) = Unit - - open fun onAuth( - relay: Relay, - challenge: String, - ) = Unit - - open fun onNotify( - relay: Relay, - description: String, - ) = Unit - - open fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) = Unit - - open fun onBeforeSend( - relay: Relay, - event: Event, - ) = Unit - - open fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) = Unit - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt deleted file mode 100644 index aeb7cf6320..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.SimpleClientRelay -import com.vitorpamplona.quartz.nip01Core.relay.SubscriptionCollection -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent - -enum class FeedType { - FOLLOWS, - PUBLIC_CHATS, - PRIVATE_DMS, - GLOBAL, - SEARCH, - WALLET_CONNECT, -} - -val ALL_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH) - -val COMMON_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - -val EVENT_FINDER_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL) - -class RelaySubFilter( - val url: String, - val activeTypes: Set, - val subs: SubscriptionCache, -) : SubscriptionCollection { - fun isMatch(filter: TypedFilter) = activeTypes.any { it in filter.types } && filter.isValidFor(url) - - fun match(filters: List): Boolean = - filters.any { filter -> - isMatch(filter) - } - - override fun isActive(subscriptionId: String): Boolean = subs.isActive(subscriptionId) && match(subs.getSubscriptionFilters(subscriptionId)) - - override fun getFilters(subscriptionId: String) = filter(subs.getSubscriptionFilters(subscriptionId)) - - override fun allSubscriptions(): List = - subs.allSubscriptions().mapNotNull { filter -> - val filters = filter(filter.value) - if (filters.isNotEmpty()) { - com.vitorpamplona.quartz.nip01Core.relay - .Subscription(filter.key, filters) - } else { - null - } - } - - override fun match( - subscriptionId: String, - event: Event, - ): Boolean = subs.getSubscriptionFilters(subscriptionId).any { it.filter.match(event, url) } - - fun filter(filters: List): List = - filters.mapNotNull { filter -> - if (isMatch(filter)) { - filter.filter.toRelay(url) - } else { - null - } - } -} - -class Relay( - val url: String, - val read: Boolean = true, - val write: Boolean = true, - val forceProxy: Boolean = false, - val activeTypes: Set, - socketBuilderFactory: WebsocketBuilderFactory, - subs: SubscriptionCache, -) : SimpleClientRelay.Listener { - private var listeners = setOf() - - val relaySubFilter = RelaySubFilter(url, activeTypes, subs) - val inner = SimpleClientRelay(url, socketBuilderFactory.build(url, forceProxy), relaySubFilter, this@Relay, RelayStats.get(url)) - - val brief = RelayBriefInfoCache.get(url) - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun isConnected() = inner.isConnected() - - fun connect() = inner.connect() - - fun connectAndRunAfterSync(onConnected: () -> Unit) { - // BRB is crashing OkHttp Deflater object :( - if (url.contains("brb.io")) return - - inner.connectAndRunAfterSync(onConnected) - } - - fun sendOutbox() = inner.sendOutbox() - - fun disconnect() = inner.disconnect() - - fun sendFilter( - requestId: String, - filters: List, - ) { - if (read) { - inner.sendRequest(requestId, relaySubFilter.filter(filters)) - } - } - - fun connectAndSendFiltersIfDisconnected() = inner.connectAndSendFiltersIfDisconnected() - - fun renewFilters() = inner.renewSubscriptions() - - fun sendOverride(signedEvent: Event) = inner.send(signedEvent) - - fun send(signedEvent: Event) { - if (signedEvent is RelayAuthEvent || write) { - inner.send(signedEvent) - } - } - - fun close(subscriptionId: String) = inner.close(subscriptionId) - - fun isSameRelayConfig(other: RelaySetupInfoToConnect): Boolean = - url == other.url && - forceProxy == other.forceProxy && - write == other.write && - read == other.read && - activeTypes == other.feedTypes - - override fun onEvent( - relay: SimpleClientRelay, - subscriptionId: String, - event: Event, - time: Long, - afterEOSE: Boolean, - ) = listeners.forEach { it.onEvent(this, subscriptionId, event, time, afterEOSE) } - - override fun onError( - relay: SimpleClientRelay, - subscriptionId: String, - error: Error, - ) = listeners.forEach { it.onError(this, subscriptionId, error) } - - override fun onEOSE( - relay: SimpleClientRelay, - subscriptionId: String, - time: Long, - ) = listeners.forEach { it.onEOSE(this, subscriptionId, time) } - - override fun onRelayStateChange( - relay: SimpleClientRelay, - type: RelayState, - ) = listeners.forEach { it.onRelayStateChange(this, type) } - - override fun onSendResponse( - relay: SimpleClientRelay, - eventId: String, - success: Boolean, - message: String, - ) = listeners.forEach { it.onSendResponse(this, eventId, success, message) } - - override fun onAuth( - relay: SimpleClientRelay, - challenge: String, - ) = listeners.forEach { it.onAuth(this, challenge) } - - override fun onNotify( - relay: SimpleClientRelay, - description: String, - ) = listeners.forEach { it.onNotify(this, description) } - - override fun onClosed( - relay: SimpleClientRelay, - subscriptionId: String, - message: String, - ) { } - - override fun onSend( - relay: SimpleClientRelay, - msg: String, - success: Boolean, - ) = listeners.forEach { it.onSend(this, msg, success) } - - override fun onBeforeSend( - relay: SimpleClientRelay, - event: Event, - ) = listeners.forEach { it.onBeforeSend(this, event) } - - interface Listener { - fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - time: Long, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: Relay, - subscriptionId: String, - time: Long, - ) - - fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) - - fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onRelayStateChange( - relay: Relay, - type: RelayState, - ) - - /** Relay sent a notification */ - fun onNotify( - relay: Relay, - description: String, - ) - - fun onBeforeSend( - relay: Relay, - event: Event, - ) - - fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt deleted file mode 100644 index 821c9119ef..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch - -/** - * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. - */ -class RelayPool : Relay.Listener { - private var relays = listOf() - private var listeners = setOf() - - // Backing property to avoid flow emissions from other classes - private var lastStatus = RelayPoolStatus(0, 0) - private val _statusFlow = - MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val statusFlow: SharedFlow = _statusFlow.asSharedFlow() - - fun availableRelays(): Int = relays.size - - fun connectedRelays(): Int = relays.count { it.isConnected() } - - fun getRelay(url: String): Relay? = relays.firstOrNull { it.url == url } - - fun getRelays(url: String): List = relays.filter { it.url == url } - - fun getAll() = relays - - fun runCreatingIfNeeded( - relay: Relay, - timeout: Long = 60000, - onDone: (() -> Unit)? = null, - whenConnected: (Relay) -> Unit, - ) { - synchronized(this) { - val matching = getRelays(relay.url) - if (matching.isNotEmpty()) { - matching.forEach { whenConnected(it) } - } else { - addRelay(relay) - - relay.connectAndRunAfterSync { - whenConnected(relay) - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - delay(timeout) // waits for a reply - relay.disconnect() - removeRelay(relay) - - if (onDone != null) { - onDone() - } - } - } - } - } - } - - fun loadRelays(relayList: List) { - check(relayList.isNotEmpty()) { "Relay list should never be empty" } - relayList.forEach { addRelayInner(it) } - updateStatus() - } - - fun unloadRelays() { - relays.forEach { it.unregister(this) } - relays = listOf() - } - - fun requestAndWatch() { - checkNotInMainThread() - - relays.forEach { it.connect() } - } - - fun sendFilter( - subscriptionId: String, - filters: List, - ) { - relays.forEach { relay -> - relay.sendFilter(subscriptionId, filters) - } - } - - fun connectAndSendFiltersIfDisconnected() { - relays.forEach { it.connectAndSendFiltersIfDisconnected() } - } - - fun sendToSelectedRelays( - list: List, - signedEvent: Event, - ) { - list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } } - } - - fun send(signedEvent: Event) { - relays.forEach { it.send(signedEvent) } - } - - fun sendOverride(signedEvent: Event) { - relays.forEach { it.sendOverride(signedEvent) } - } - - fun close(subscriptionId: String) { - relays.forEach { it.close(subscriptionId) } - } - - fun disconnect() { - relays.forEach { it.disconnect() } - } - - fun addRelay(relay: Relay) { - addRelayInner(relay) - updateStatus() - } - - private fun addRelayInner(relay: Relay) { - relay.register(this) - relays += relay - } - - fun removeRelay(relay: Relay) { - relay.unregister(this) - relays = relays.minus(relay) - updateStatus() - } - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - interface Listener { - fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) - - fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) - - fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onNotify( - relay: Relay, - description: String, - ) - - fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) - - fun onBeforeSend( - relay: Relay, - event: Event, - ) - - fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) - } - - override fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, arrivalTime, afterEOSE) } - } - - override fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - updateStatus() - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - listeners.forEach { it.onEOSE(relay, subscriptionId, arrivalTime) } - updateStatus() - } - - override fun onRelayStateChange( - relay: Relay, - type: RelayState, - ) { - listeners.forEach { it.onRelayStateChange(type, relay) } - } - - override fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - listeners.forEach { it.onAuth(relay, challenge) } - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - listeners.forEach { it.onNotify(relay, description) } - } - - override fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) { - listeners.forEach { it.onSend(relay, msg, success) } - } - - override fun onBeforeSend( - relay: Relay, - event: Event, - ) { - listeners.forEach { it.onBeforeSend(relay, event) } - } - - private fun updateStatus() { - val connected = connectedRelays() - val available = availableRelays() - if (lastStatus.connected != connected || lastStatus.available != available) { - lastStatus = RelayPoolStatus(connected, available) - _statusFlow.tryEmit(lastStatus) - } - } -} - -@Immutable -data class RelayPoolStatus( - val connected: Int, - val available: Int, - val isConnected: Boolean = connected > 0, -) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt deleted file mode 100644 index 8bfb03ee41..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays.datasources - -import android.util.Log -import com.vitorpamplona.ammolite.relays.BundledUpdate -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Semantically groups Nostr filters and subscriptions in data source objects that - * maintain the desired active filter with the relay. - * - * This model also allows each datasource to observe any new events from the network, - * regardless of the subscription - */ -abstract class NostrDataSource( - val client: NostrClient, -) { - private var subscriptions = SubscriptionSet() - val stats = SubscriptionStats() - - var changingFilters = AtomicBoolean() - - private var active: Boolean = false - - private val clientListener = - object : NostrClient.Listener { - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - if (subscriptions.contains(subscriptionId)) { - stats.add(subscriptionId, event.kind) - - consume(event, relay) - - if (afterEOSE) { - markAsEOSE(subscriptionId, relay, arrivalTime) - } - } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - if (subscriptions.contains(subscriptionId)) { - markAsEOSE(subscriptionId, relay, arrivalTime) - } - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) {} - - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - if (success) { - markAsSeenOnRelay(eventId, relay) - } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - auth(relay, challenge) - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - notify(relay, description) - } - } - - init { - Log.d("${this.javaClass.simpleName}", "Init, Subscribe") - client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") - stop() - client.unsubscribe(clientListener) - bundler.cancel() - } - - open fun start() { - Log.d("${this.javaClass.simpleName}", "Start") - active = true - invalidateFilters() - } - - @OptIn(DelicateCoroutinesApi::class) - open fun stop() { - active = false - Log.d("${this.javaClass.simpleName}", "Stop") - - subscriptions.forEach { subscription -> - client.close(subscription.id) - subscription.reset() - } - } - - fun getSub(subId: String) = subscriptions.get(subId) - - fun requestNewSubscription(onEOSE: ((Long, String) -> Unit)? = null): Subscription = subscriptions.newSub(onEOSE) - - fun dismissSubscription(subId: String) { - getSub(subId)?.let { dismissSubscription(it) } - } - - fun dismissSubscription(subscription: Subscription) { - client.close(subscription.id) - subscription.reset() - subscriptions.remove(subscription) - } - - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) - - fun invalidateFilters() { - bundler.invalidate { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() - } - } - - suspend fun resetFiltersSuspend() { - // only runs one at a time. Ignores the others - if (changingFilters.compareAndSet(false, true)) { - Log.d("${this.javaClass.simpleName}", "resetFiltersSuspend $active") - try { - resetFiltersSuspendInner() - } finally { - changingFilters.getAndSet(false) - } - } - } - - private fun resetFiltersSuspendInner() { - // saves the channels that are currently active - val activeSubscriptions = subscriptions.actives() - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters } - - // updates all filters - updateSubscriptions() - - // Makes sure to only send an updated filter when it actually changes. - subscriptions.forEach { newSubscriptionFilters -> - val currentFilters = currentFilters[newSubscriptionFilters.id] - updateRelaysIfNeeded(newSubscriptionFilters, currentFilters) - } - } - - fun updateRelaysIfNeeded( - updatedSubscription: Subscription, - currentFilters: List?, - ) { - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - - val isActive = client.isActive(updatedSubscription.id) - - if (!isActive && updatedSubscriptionNewFilters != null) { - // Filter was removed from the active list - // but it is supposed to be there. Send again. - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - if (currentFilters != null) { - if (updatedSubscriptionNewFilters == null) { - // was active and is not active anymore, just close. - client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.hasChangedFiltersFrom(currentFilters)) { - client.close(updatedSubscription.id) - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - // hasn't changed, does nothing. - // unless the relay has disconnected, then reconnect. - if (active) { - client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } else { - if (updatedSubscriptionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } - } - - open fun consume( - event: Event, - relay: Relay, - ) = Unit - - open fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) = Unit - - open fun markAsEOSE( - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - ) { - subscriptions[subscriptionId]?.callEose( - arrivalTime, - relay.url, - ) - } - - open fun auth( - relay: Relay, - challenge: String, - ) = Unit - - open fun notify( - relay: Relay, - description: String, - ) = Unit - - abstract fun updateSubscriptions() -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt deleted file mode 100644 index 91f56e7bcc..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays.datasources - -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.NormalFilter -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import java.util.UUID - -data class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), - val onEose: ((time: Long, relayUrl: String) -> Unit)? = null, -) { - var typedFilters: List? = null // Inactive when null - - fun reset() { - typedFilters = null - } - - fun callEose( - time: Long, - relay: String, - ) { - onEose?.let { it(time, relay) } - } - - fun hasChangedFiltersFrom(otherFilters: List?): Boolean { - if (typedFilters == null && otherFilters == null) return false - if (typedFilters?.size != otherFilters?.size) return true - - typedFilters?.forEachIndexed { index, typedFilter -> - val otherFilter = otherFilters?.getOrNull(index) ?: return true - - if (typedFilter.filter is SincePerRelayFilter && otherFilter.filter is SincePerRelayFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - if (typedFilter.filter is SinceAuthorPerRelayFilter && otherFilter.filter is SinceAuthorPerRelayFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - if (typedFilter.filter is NormalFilter && otherFilter.filter is NormalFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - return true - } - return false - } - - fun isDifferent( - filter1: SincePerRelayFilter, - filter2: SincePerRelayFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } - - fun isDifferent( - filter1: SinceAuthorPerRelayFilter, - filter2: SinceAuthorPerRelayFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } - - fun isDifferent( - filter1: NormalFilter, - filter2: NormalFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt deleted file mode 100644 index daf7939174..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays.datasources - -import com.vitorpamplona.ammolite.relays.BundledUpdate -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.quartz.nip01Core.core.Event -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Semantically groups Nostr filters and subscriptions in data source objects that - * maintain the desired active filter with the relay. - */ -class SubscriptionController( - val client: NostrClient, - val updateSubscriptions: () -> Unit, -) : SubscriptionControllerService { - private val subscriptions = SubscriptionSet() - private var active: Boolean = false - private val changingFilters = AtomicBoolean() - - val stats = SubscriptionStats() - - private val clientListener = - object : NostrClient.Listener { - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - if (subscriptions.contains(subscriptionId)) { - stats.add(subscriptionId, event.kind) - if (afterEOSE) { - runAfterEOSE(subscriptionId, relay, arrivalTime) - } - } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - if (subscriptions.contains(subscriptionId)) { - runAfterEOSE(subscriptionId, relay, arrivalTime) - } - } - } - - private fun runAfterEOSE( - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - ) { - subscriptions[subscriptionId]?.callEose(arrivalTime, relay.url) - } - - init { - client.subscribe(clientListener) - } - - override fun destroy() { - // makes sure to run - stop() - client.unsubscribe(clientListener) - bundler.cancel() - } - - override fun start() { - active = true - invalidateFilters() - } - - @OptIn(DelicateCoroutinesApi::class) - override fun stop() { - active = false - - subscriptions.forEach { subscription -> - client.close(subscription.id) - subscription.reset() - } - } - - override fun printStats(tag: String) = stats.printCounter(tag) - - fun getSub(subId: String) = subscriptions.get(subId) - - fun requestNewSubscription(onEOSE: ((Long, String) -> Unit)? = null): Subscription = subscriptions.newSub(onEOSE) - - fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } - - fun dismissSubscription(subscription: Subscription) { - client.close(subscription.id) - subscription.reset() - subscriptions.remove(subscription) - } - - fun isUpdatingFilters() = changingFilters.get() - - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) - - override fun invalidateFilters() { - bundler.invalidate { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() - } - } - - private fun resetFiltersSuspend() { - // only runs one at a time. Ignores the others - if (changingFilters.compareAndSet(false, true)) { - try { - resetFiltersSuspendInner() - } finally { - changingFilters.getAndSet(false) - } - } - } - - private fun resetFiltersSuspendInner() { - // saves the channels that are currently active - val activeSubscriptions = subscriptions.actives() - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to client.getSubscriptionFiltersOrNull(it.id) } - - // updates all filters - updateSubscriptions() - - // Makes sure to only send an updated filter when it actually changes. - subscriptions.forEach { newSubscriptionFilters -> - val currentFilters = currentFilters[newSubscriptionFilters.id] - updateRelaysIfNeeded(newSubscriptionFilters, currentFilters) - } - } - - fun updateRelaysIfNeeded( - updatedSubscription: Subscription, - currentFilters: List?, - ) { - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - - val isActive = client.isActive(updatedSubscription.id) - - if (!isActive && updatedSubscriptionNewFilters != null) { - // Filter was removed from the active list - // but it is supposed to be there. Send again. - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - if (currentFilters != null) { - if (updatedSubscriptionNewFilters == null) { - // was active and is not active anymore, just close. - client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.hasChangedFiltersFrom(currentFilters)) { - client.close(updatedSubscription.id) - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - // hasn't changed, does nothing. - // unless the relay has disconnected, then reconnect. - if (active) { - client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } else { - if (updatedSubscriptionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt similarity index 80% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt rename to ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt index f07ebed6ae..67dfd8f2f4 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt @@ -23,14 +23,27 @@ package com.vitorpamplona.ammolite.relays.filters /* * Wrapper class to allow changing in EOSE without modifying the list it is included within */ -class EOSETime( - var time: Long, +class MutableTime( + startTime: Long, ) { + var time: Long = startTime + private set + override fun toString(): String = time.toString() - fun update(newTime: Long) { + fun updateIfNewer(newTime: Long) { if (newTime > time) { time = newTime } } + + fun updateIfOlder(newTime: Long) { + if (newTime < time) { + time = newTime + } + } + + fun minus(delta: Int) = MutableTime(time - delta) + + fun copy() = MutableTime(time) } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt deleted file mode 100644 index b837f826ca..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays.filters - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class NormalFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Long? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - override fun isValidFor(url: String) = true - - override fun toRelay(forRelay: String) = Filter(ids, authors, kinds, tags, since, until, limit, search) - - override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors, kinds, tags, since, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until) - - override fun toDebugJson(): String { - val obj = FilterSerializer.toJsonObject(ids, authors, kinds, tags, since, until, limit, search) - return EventMapper.toJson(obj) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt deleted file mode 100644 index 38abad5994..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays.filters - -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class SinceAuthorPerRelayFilter( - val ids: List? = null, - val authors: Map>? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - // This only exists because some relays consider empty arrays as null and return everything. - // So, if there is an author list, but no list for the specific relay or if the list is empty - // don't send it. - override fun isValidFor(forRelay: String) = authors == null || !authors[forRelay].isNullOrEmpty() - - override fun toRelay(forRelay: String) = Filter(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun toJson(forRelay: String): String = FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until) - - override fun toDebugJson(): String { - val factory = JsonNodeFactory.instance - val obj = FilterSerializer.toJsonObject(ids, null, kinds, tags, null, until, limit, search) - authors?.run { - if (isNotEmpty()) { - val jsonObjectPerRelayAuthors = factory.objectNode() - entries.forEach { relayAuthorPairs -> - jsonObjectPerRelayAuthors.replace( - relayAuthorPairs.key, - factory.arrayNode(relayAuthorPairs.value.size).apply { - relayAuthorPairs.value.forEach { - add(it) - } - }, - ) - } - obj.replace("authors", jsonObjectPerRelayAuthors) - } - } - - since?.run { - if (isNotEmpty()) { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - obj.replace("since", jsonObjectSince) - } - } - return EventMapper.mapper.writeValueAsString(obj) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt deleted file mode 100644 index 522f368a3c..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays.filters - -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class SincePerRelayFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - override fun isValidFor(url: String) = true - - override fun toRelay(forRelay: String) = Filter(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors, kinds, tags, since?.get(forRelay)?.time, until) - - override fun toDebugJson(): String { - val factory = JsonNodeFactory.instance - val obj = FilterSerializer.toJsonObject(ids, authors, kinds, tags, null, until, limit, search) - - since?.run { - if (isNotEmpty()) { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - obj.replace("since", jsonObjectSince) - } - } - return EventMapper.toJson(obj) - } -} diff --git a/benchmark/build.gradle b/benchmark/build.gradle index 2f907d1718..a45aa4aaeb 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -7,12 +7,12 @@ plugins { } android { - namespace 'com.vitorpamplona.amethyst.benchmark' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.amethyst.benchmark' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() // Enable measuring on an emulator, or devices with low battery testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner' @@ -36,12 +36,12 @@ android { } release { isDefault = false - minifyEnabled true + minifyEnabled = true } create("benchmark") { isDefault = true initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt new file mode 100644 index 0000000000..3f502cb4db --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.utils.LargeCache +import org.junit.Assert.assertTrue +import java.util.function.Consumer +import java.util.zip.GZIPInputStream + +open class BaseLargeCacheBenchmark { + companion object { + fun getEventDB(): List { + // This file includes duplicates + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") + + return JsonMapper.mapper.readValue>( + GZIPInputStream(fullDBInputStream), + ) + } + } + + fun getLargeCache(db: List): LargeCache { + val cache = LargeCache() + + db.forEach { event -> + cache.getOrCreate(event.id) { key -> event } + } + + return cache + } + + fun hasId(event: Event) { + assertTrue(event.id.isNotEmpty()) + } + + val consumer = + Consumer { + hasId(it) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt index 2c171ecee9..2e0bf231a4 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt @@ -27,7 +27,7 @@ import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.LargeCache import org.junit.Assert.assertTrue import org.junit.Rule @@ -44,7 +44,7 @@ open class BaseCacheBenchmark { // This file includes duplicates val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") - return EventMapper.mapper.readValue>( + return JsonMapper.mapper.readValue>( GZIPInputStream(fullDBInputStream), ) } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt index e15b499861..d7489336bc 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt @@ -24,10 +24,11 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip01Core.verifyId import com.vitorpamplona.quartz.nip01Core.verifySignature +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.assertTrue import org.junit.Rule @@ -47,28 +48,28 @@ class EventBenchmark { @Test fun parseComplete() { benchmarkRule.measureRepeated { - val tree = EventMapper.mapper.readTree(reqResponseEvent) - val event = EventMapper.fromJson(tree[2]) + val tree = JsonMapper.mapper.readTree(reqResponseEvent) + val event = JsonMapper.fromJson(tree[2]) assertTrue(event.verify()) } } @Test fun parseREQString() { - benchmarkRule.measureRepeated { EventMapper.mapper.readTree(reqResponseEvent) } + benchmarkRule.measureRepeated { JsonMapper.mapper.readTree(reqResponseEvent) } } @Test fun parseEvent() { - val msg = EventMapper.mapper.readTree(reqResponseEvent) + val msg = JsonMapper.mapper.readTree(reqResponseEvent) - benchmarkRule.measureRepeated { EventMapper.fromJson(msg[2]) } + benchmarkRule.measureRepeated { JsonMapper.fromJson(msg[2]) } } @Test fun checkId() { - val msg = EventMapper.mapper.readTree(reqResponseEvent) - val event = EventMapper.fromJson(msg[2]) + val msg = JsonMapper.mapper.readTree(reqResponseEvent) + val event = JsonMapper.fromJson(msg[2]) benchmarkRule.measureRepeated { // Should pass assertTrue(event.verifyId()) @@ -77,8 +78,8 @@ class EventBenchmark { @Test fun checkSignature() { - val msg = EventMapper.mapper.readTree(reqResponseEvent) - val event = EventMapper.fromJson(msg[2]) + val msg = JsonMapper.mapper.readTree(reqResponseEvent) + val event = JsonMapper.fromJson(msg[2]) benchmarkRule.measureRepeated { // Should pass assertTrue(event.verifySignature()) @@ -90,7 +91,7 @@ class EventBenchmark { val now = TimeUtils.now() val tags = arrayOf(arrayOf("")) benchmarkRule.measureRepeated { - EventFactory.create("id", "pubkey", now, 1, tags, "content", "sig") + EventFactory.create("id", "pubkey", now, 1, tags, "content", "sig") } } @@ -99,7 +100,7 @@ class EventBenchmark { val now = TimeUtils.now() val tags = arrayOf(arrayOf("")) benchmarkRule.measureRepeated { - EventFactory.create("id", "pubkey", now, 30818, tags, "content", "sig") + EventFactory.create("id", "pubkey", now, 30818, tags, "content", "sig") } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt index ec0e198362..b343ff0c1d 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt @@ -25,7 +25,7 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.generateId -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.verifyId import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue @@ -45,7 +45,7 @@ class EventCmdHasherBenchmark { @Test fun checkIDHashKind1WihtoutTags() { - val event = EventMapper.fromJson(EventMapper.mapper.readTree(reqResponseEvent)) + val event = JsonMapper.fromJson(JsonMapper.mapper.readTree(reqResponseEvent)) benchmarkRule.measureRepeated { // Should pass diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt index 607cf541f8..cad8498c35 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt @@ -32,14 +32,11 @@ import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import junit.framework.TestCase +import kotlinx.coroutines.runBlocking import org.junit.Assert -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -54,102 +51,78 @@ class GiftWrapBenchmark { fun basePerformanceTest( message: String, expectedLength: Int, - ) { + ) = runBlocking { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - var events: NIP17Factory.Result? = null - val countDownLatch = CountDownLatch(1) - - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - listOf(PTag(receiver.pubKey)), - ), - sender, - ) { - events = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - val countDownLatch2 = CountDownLatch(1) + val result = + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey)), + ), + sender, + ) Assert.assertEquals( expectedLength, - events!! - .wraps - .sumOf { it.toJson().length }, + result.wraps.sumOf { it.toJson().length }, ) // Simulate Receiver - events!!.wraps.forEach { + result.wraps.forEach { it.checkSignature() val keyToUse = if (it.recipientPubKey() == sender.pubKey) sender else receiver - it.unwrap(keyToUse) { event -> - event.checkSignature() + val event = it.unwrapThrowing(keyToUse) + event.checkSignature() - if (event is SealedRumorEvent) { - event.unseal(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - countDownLatch2.countDown() - } - } else { - Assert.fail("Wrong Event") - } + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(keyToUse) + Assert.assertEquals(message, innerData.content) + } else { + Assert.fail("Wrong Event") } } - - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) } fun receivePerformanceTest(message: String) { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - var giftWrap: GiftWrapEvent? = null - val countDownLatch = CountDownLatch(1) + val result = + runBlocking { + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey)), + ), + sender, + ) + } - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - listOf(PTag(receiver.pubKey)), - ), - sender, - ) { - giftWrap = it.wraps.first() - countDownLatch.countDown() - } + val giftWrap = result.wraps.first() - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - val keyToUse = if (giftWrap!!.recipientPubKey() == sender.pubKey) sender else receiver - val giftWrapJson = giftWrap!!.toJson() + val keyToUse = if (giftWrap.recipientPubKey() == sender.pubKey) sender else receiver + val giftWrapJson = giftWrap.toJson() // Simulate Receiver benchmarkRule.measureRepeated { - val counter = CountDownLatch(1) + runBlocking { + val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent + wrap.checkSignature() - val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent - wrap.checkSignature() - - wrap.unwrap(keyToUse) { seal -> + val seal = wrap.unwrapThrowing(keyToUse) seal.checkSignature() if (seal is SealedRumorEvent) { - seal.unseal(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - counter.countDown() - } + val innerData = seal.unsealThrowing(keyToUse) + Assert.assertEquals(message, innerData.content) } else { Assert.fail("Wrong Event") } } - - TestCase.assertTrue(counter.await(1, TimeUnit.SECONDS)) } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 897b462244..3472b7794e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -40,12 +40,10 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -60,78 +58,56 @@ class GiftWrapReceivingBenchmark { fun createWrap( sender: NostrSigner, receiver: NostrSigner, - ): GiftWrapEvent { - val countDownLatch = CountDownLatch(1) - var wrap: GiftWrapEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), + ): GiftWrapEvent = + runBlocking { + GiftWrapEvent.create( + event = + SealedRumorEvent.create( + event = + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = listOf(PTag(receiver.pubKey)), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ), + encryptTo = receiver.pubKey, + signer = sender, ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - SealedRumorEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey, - ) { - wrap = it - countDownLatch.countDown() - } - } + recipientPubKey = receiver.pubKey, + ) } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - return wrap!! - } - fun createSeal( sender: NostrSigner, receiver: NostrSigner, - ): SealedRumorEvent { - val countDownLatch = CountDownLatch(1) - var seal: SealedRumorEvent? = null + ): SealedRumorEvent = + runBlocking { + val msg = + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { SealedRumorEvent.create( - event = it, + event = msg, encryptTo = receiver.pubKey, signer = sender, - ) { - seal = it - countDownLatch.countDown() - } + ) } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - return seal!! - } - @Test fun parseWrapFromString() { val sender = NostrSignerInternal(KeyPair()) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt index 9e1a770c11..3c310d28dd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt @@ -32,12 +32,10 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -55,25 +53,21 @@ class GiftWrapSigningBenchmark { val receiver = NostrSignerInternal(KeyPair()) benchmarkRule.measureRepeated { - val countDownLatch = CountDownLatch(1) - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - countDownLatch.countDown() + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) } } @@ -82,40 +76,31 @@ class GiftWrapSigningBenchmark { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(1) - - var msg: ChatMessageEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - msg = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - SealedRumorEvent.create( - event = msg!!, - encryptTo = receiver.pubKey, - signer = sender, - ) { - countDownLatch2.countDown() + val msg = + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + benchmarkRule.measureRepeated { + runBlocking { + SealedRumorEvent.create( + event = msg, + encryptTo = receiver.pubKey, + signer = sender, + ) + } } } @@ -124,44 +109,39 @@ class GiftWrapSigningBenchmark { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(1) - - var seal: SealedRumorEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - SealedRumorEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - seal = it - countDownLatch.countDown() + val msg = + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val seal = + runBlocking { + SealedRumorEvent.create( + event = msg, + encryptTo = receiver.pubKey, + signer = sender, + ) + } benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - GiftWrapEvent.create( - event = seal!!, - recipientPubKey = receiver.pubKey, - ) { - countDownLatch2.countDown() + runBlocking { + GiftWrapEvent.create( + event = seal!!, + recipientPubKey = receiver.pubKey, + ) } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) } } @@ -170,40 +150,40 @@ class GiftWrapSigningBenchmark { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(1) - - var wrap: GiftWrapEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - SealedRumorEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey, - ) { - wrap = it - countDownLatch.countDown() - } + val msg = + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val seal = + runBlocking { + SealedRumorEvent.create( + event = msg, + encryptTo = receiver.pubKey, + signer = sender, + ) + } - benchmarkRule.measureRepeated { wrap!!.toJson() } + val wrap = + runBlocking { + GiftWrapEvent.create( + event = seal, + recipientPubKey = receiver.pubKey, + ) + } + + benchmarkRule.measureRepeated { wrap.toJson() } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt index 5afb1b72c9..80c16fdfe9 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt @@ -27,6 +27,7 @@ import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.RandomInstance import org.junit.Rule import org.junit.Test @@ -52,6 +53,7 @@ class HintIndexerBenchmark { .readBytes() .toString(Charset.forName("utf-8")) .split("\n") + .mapNotNull(RelayUrlNormalizer::normalizeOrNull) } @Test @@ -74,7 +76,7 @@ class HintIndexerBenchmark { val key = keys.random() benchmarkRule.measureRepeated { - indexer.getKey(key) + indexer.hintsForKey(key) } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt index be316563cf..325c72329c 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt @@ -23,54 +23,14 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.utils.LargeCache -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.util.Arrays -import java.util.function.Consumer -import java.util.zip.GZIPInputStream - -open class BaseLargeCacheBenchmark { - fun getEventDB(): List { - // This file includes duplicates - val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") - - return EventMapper.mapper.readValue>( - GZIPInputStream(fullDBInputStream), - ) - } - - fun getLargeCache(db: List): LargeCache { - val cache = LargeCache() - - db.forEach { - cache.getOrCreate(it.id) { key -> - it - } - } - - return cache - } - - fun hasId(event: Event) { - assertTrue(event.id.isNotEmpty()) - } - - val consumer = - Consumer { - hasId(it) - } -} +import kotlin.collections.distinctBy @RunWith(AndroidJUnit4::class) -class LargeCacheForEachBenchmark : BaseLargeCacheBenchmark() { +class LargeCacheBenchmark : BaseLargeCacheBenchmark() { @get:Rule val benchmarkRule = BenchmarkRule() // 191,353 ns 0 allocs Trace EMULATOR_LargeCacheForEachBenchmark.benchForEachConsumerList diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt new file mode 100644 index 0000000000..9dd3897437 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import android.util.Log.e +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.collections.flatten + +@RunWith(AndroidJUnit4::class) +class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val allEvents = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + val firstThousandEvents = allEvents.take(1000) + } + + @Test + fun benchInserting1000Events() { + val context = ApplicationProvider.getApplicationContext() + benchmarkRule.measureRepeated { + val db = + this.runWithTimingDisabled { + val db = EventStore(context, "test1.db") + db.store.clearDB() + db + } + firstThousandEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + this.runWithTimingDisabled { + db.store.clearDB() + db.close() + } + } + } + + @Test + fun bench40DeletionRequestsEvents() { + val context = ApplicationProvider.getApplicationContext() + val deletions = allEvents.filterIsInstance() + val deletionIds = deletions.map { it.deleteEventIds() }.flatten().toSet() + val deletionAddresses = deletions.map { it.deleteAddressIds() }.flatten().toSet() + + val toBeDeletedEvents = + allEvents.filter { + (it.id in deletionIds || (it is AddressableEvent && it.addressTag() in deletionAddresses)) + } + + benchmarkRule.measureRepeated { + val db = + this.runWithTimingDisabled { + val db = EventStore(context, "test1.db") + db.store.clearDB() + toBeDeletedEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + + db + } + + deletions.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + + runWithTimingDisabled { + db.store.clearDB() + db.close() + } + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt new file mode 100644 index 0000000000..805cb08b5d --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LargeDBQueryingBenchmark : BaseLargeCacheBenchmark() { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val allEvents = getEventDB().distinctBy { it.id }.sortedBy { it.createdAt } + } + + lateinit var db: EventStore + + @Before + fun setup() { + println("Running Setup") + val context = ApplicationProvider.getApplicationContext() + + db = EventStore(context, "allEvents.db") + db.store.clearDB() + allEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBQueryingBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun benchQuerying1000Events() { + benchmarkRule.measureRepeated { + db.query(Filter(limit = 1000)) + } + } + + @Test + fun benchQuerying1614GiftWrapsEvents() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + ), + ) + } + } + + @Test + fun benchQueryingEventsByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingMetadataByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingRelayListByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingEmptyReturnByTags() { + benchmarkRule.measureRepeated { + db.query(Filter(tags = mapOf("p" to listOf("176d972f60dcdc3212ed8c92ef85065c176d972f60dcdc3212ed8c92ef85065c")))) + } + } + + @Test + fun benchQueryingEmptyReturnByIds() { + benchmarkRule.measureRepeated { + db.query(Filter(ids = listOf("176d972f60dcdc3212ed8c92ef85065c176d972f60dcdc3212ed8c92ef85065c"))) + } + } +} diff --git a/commons/build.gradle b/commons/build.gradle index 37614f930e..82c054688c 100644 --- a/commons/build.gradle +++ b/commons/build.gradle @@ -7,12 +7,12 @@ plugins { } android { - namespace 'com.vitorpamplona.amethyst.commons' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.amethyst.commons' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -25,7 +25,7 @@ android { } create("benchmark") { initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } compileOptions { @@ -34,7 +34,7 @@ android { } buildFeatures { - compose true + compose = true } } diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt index a7e14efd6b..678a654fbc 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt @@ -96,7 +96,7 @@ class BlurhashTest { @Test fun testLorikeet() { val blurhash = load("/lorikeet.jpg").toBlurhash() - assertEquals("rFDcT@_LNs#p%Mt*nNM}E2VrIVX6VuV@WUo{xtjv9]RRw[OXS}rrWFX9w{OZxaxWNHX4n\$M}NGaK%0RkM}w{xto|jFs,Sh-Tj]bcwJnjXSxZs.NI", blurhash) + assertEquals("rFDcT@_LNs#:-pyBnhRRE2Z~MyX5VuV@WUo{xta\$9]RQw[OXS}rrWFXSw|OsxaxWNHSwn~M}NGaK%0RkM}w{xto|jGs+Sh-Tj]W?wJnjXSxGs.NI", blurhash) } private fun load(filename: String): Bitmap = diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt index 301a0180a9..cc26f55498 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -29,7 +29,7 @@ import java.util.regex.Pattern class Base64Image { companion object { - val pattern = Pattern.compile("data:image/(${RichTextParser.Companion.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") + val pattern = Pattern.compile("data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") fun isBase64(content: String): Boolean { val matcher = pattern.matcher(content) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 088fd830dd..733dfdac20 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt @@ -32,8 +32,9 @@ fun produceCachedStateAsync( ): State = @Suppress("ProduceStateDoesNotAssignValue") produceState(initialValue = cache.cached(key), key1 = key) { - cache.update(key) { - value = it + val newValue = cache.update(key) + if (newValue != value) { + value = newValue } } @@ -45,18 +46,16 @@ fun produceCachedStateAsync( ): State = @Suppress("ProduceStateDoesNotAssignValue") produceState(initialValue = cache.cached(updateValue), key1 = key) { - cache.update(updateValue) { - value = it + val newValue = cache.update(updateValue) + if (newValue != value) { + value = newValue } } interface AsyncCachedState { fun cached(k: K): V? - suspend fun update( - k: K, - onReady: (V?) -> Unit, - ) + suspend fun update(k: K): V? } abstract class GenericBaseCacheAsync( @@ -66,23 +65,17 @@ abstract class GenericBaseCacheAsync( override fun cached(k: K): V? = cache[k] - override suspend fun update( - k: K, - onReady: (V?) -> Unit, - ) { - cache[k]?.let { onReady(it) } + override suspend fun update(k: K): V? { + cache[k]?.let { return it } - compute(k) { - if (it != null) { - cache.put(k, it) - } + val newValue = compute(k) - onReady(it) + if (newValue != null) { + cache.put(k, newValue) } + + return newValue } - abstract suspend fun compute( - key: K, - onReady: (V?) -> Unit, - ) + abstract suspend fun compute(key: K): V? } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 8306893980..6d17f3189e 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -367,11 +367,11 @@ class RichTextParser { private fun removeQueryParamsForExtensionComparison(fullUrl: String): String = if (fullUrl.contains("?")) { - fullUrl.split("?")[0].lowercase() + fullUrl.split("?")[0] } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0].lowercase() + fullUrl.split("#")[0] } else { - fullUrl.lowercase() + fullUrl } fun isImageOrVideoUrl(url: String): Boolean { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72965fb0b0..b8d3de626b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,56 +2,57 @@ accompanistAdaptive = "0.37.3" activityCompose = "1.10.1" agp = "8.11.1" -android-compileSdk = "35" +android-compileSdk = "36" android-minSdk = "26" -android-targetSdk = "35" +android-targetSdk = "36" androidKotlinGeohash = "b481c6a64e" androidxJunit = "1.2.1" -appcompat = "1.7.0" -audiowaveform = "1.1.1" +appcompat = "1.7.1" +audiowaveform = "1.1.2" benchmark = "1.3.4" benchmarkJunit4 = "1.3.4" biometricKtx = "1.2.0-alpha05" -coil = "3.2.0" -composeBom = "2025.05.00" +coil = "3.3.0" +composeBom = "2025.07.00" coreKtx = "1.16.0" +datastore = "1.1.7" espressoCore = "3.6.1" -firebaseBom = "33.13.0" -fragmentKtx = "1.8.6" -gms = "4.4.2" -jacksonModuleKotlin = "2.19.0" +firebaseBom = "34.0.0" +fragmentKtx = "1.8.8" +gms = "4.4.3" +jacksonModuleKotlin = "2.19.2" jna = "5.17.0" jtorctl = "0.4.5.7" junit = "4.13.2" -kotlin = "2.1.0" +kotlin = "2.2.0" kotlinxCollectionsImmutable = "0.4.0" -kotlinxSerialization = "1.8.1" -kotlinxSerializationPlugin = "2.0.0" +kotlinxSerialization = "1.9.0" +kotlinxSerializationPlugin = "2.2.0" languageId = "17.0.6" -lazysodiumAndroid = "5.1.0" -lifecycleRuntimeKtx = "2.9.0" -lightcompressor = "1.3.2" +lazysodiumAndroid = "5.2.0" +lifecycleRuntimeKtx = "2.9.2" +lightcompressor = "1.3.3" markdown = "e1151c8" media3 = "1.7.1" -mockk = "1.14.2" +mockk = "1.14.5" kotlinx-coroutines-test = "1.10.2" -navigationCompose = "2.9.0" -okhttp = "5.0.0-alpha.14" +navigationCompose = "2.9.2" +okhttp = "5.1.0" runner = "1.6.2" rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.17.3" -securityCryptoKtx = "1.1.0-alpha07" -spotless = "6.25.0" -torAndroid = "0.4.8.12" +secp256k1KmpJniAndroid = "0.18.0" +securityCryptoKtx = "1.1.0-beta01" +spotless = "7.2.1" +torAndroid = "0.4.8.17" translate = "17.0.3" -unifiedpush = "2.3.1" +unifiedpush = "3.0.10" urlDetector = "0.1.23" -vico-charts = "2.1.2" +vico-charts = "2.1.3" zelory = "3.0.1" -zoomable = "2.7.0" +zoomable = "2.8.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" -windowCoreAndroid = "1.3.0" +windowCoreAndroid = "1.4.0" androidxCamera = "1.4.2" [libraries] @@ -69,6 +70,7 @@ androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = " androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } @@ -101,7 +103,7 @@ coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } -firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } @@ -109,6 +111,7 @@ jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" } markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } @@ -117,6 +120,7 @@ markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", n mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } diff --git a/quartz/build.gradle b/quartz/build.gradle index 12e9db7ed2..d6e191a402 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -6,12 +6,12 @@ plugins { } android { - namespace 'com.vitorpamplona.quartz' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.quartz' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,12 +19,12 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled = true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } create("benchmark") { initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } compileOptions { @@ -47,7 +47,6 @@ kotlin { dependencies { implementation libs.androidx.core.ktx - implementation platform(libs.androidx.compose.bom) // @Immutable and @Stable @@ -58,11 +57,12 @@ dependencies { // LibSodium for ChaCha encryption (NIP-44) // Wait for @aar support in version catalogs - implementation "com.goterl:lazysodium-android:5.1.0@aar" - implementation 'net.java.dev.jna:jna:5.17.0@aar' - //implementation (libs.lazysodium.android) { artifact { type = "aar" } } - //implementation (libs.jna) { artifact { type = "aar" } } + // implementation 'com.goterl:lazysodium-android:5.2.0@aar' + // implementation 'net.java.dev.jna:jna:5.17.0@aar' + + implementation variantOf(libs.lazysodium.android) { artifactType("aar") } + implementation variantOf(libs.jna) { artifactType("aar") } // Performant Parser of JSONs into Events api libs.jackson.module.kotlin diff --git a/quartz/notebooks/Kind1Test.ipynb b/quartz/notebooks/Kind1Test.ipynb new file mode 100644 index 0000000000..707a981a83 --- /dev/null +++ b/quartz/notebooks/Kind1Test.ipynb @@ -0,0 +1,147 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Creating a new Kind 1 post" + }, + { + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "cell_type": "code", + "source": [ + "USE {\n", + " dependencies(\"fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.17.3\")\n", + " dependencies(\"com.goterl:lazysodium-java:5.1.4\")\n", + " dependencies(\"net.java.dev.jna:jna:5.17.0\")\n", + "\n", + " import(\"com.vitorpamplona.quartz.*\")\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "collapsed": true, + "ExecuteTime": { + "end_time": "2025-06-03T14:42:40.051027Z", + "start_time": "2025-06-03T14:42:40.023052Z" + } + }, + "source": [ + "import fr.acinq.secp256k1.Secp256k1\n", + "\n", + "import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair\n", + "import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal\n", + "import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync\n", + "import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent\n", + "\n", + "val signer = NostrSignerSync()\n", + "val kind1Template = TextNoteEvent.build(\"New kind 1 Post\")\n", + "val signedEvent = signer.sign(kind1Template)\n", + "\n", + "println(signedEvent)" + ], + "outputs": [ + { + "ename": "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException", + "evalue": "at Cell In[5], line 3, column 43: Unresolved reference: crypto\nat Cell In[5], line 4, column 43: Unresolved reference: signers\nat Cell In[5], line 5, column 43: Unresolved reference: signers\nat Cell In[5], line 6, column 33: Unresolved reference: nip10Notes\nat Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync\nat Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent\nat Cell In[5], line 12, column 1: Overload resolution ambiguity: \npublic inline fun println(message: Any?): Unit defined in kotlin.io\npublic inline fun println(message: Boolean): Unit defined in kotlin.io\npublic inline fun println(message: Byte): Unit defined in kotlin.io\npublic inline fun println(message: Char): Unit defined in kotlin.io\npublic inline fun println(message: CharArray): Unit defined in kotlin.io\npublic inline fun println(message: Double): Unit defined in kotlin.io\npublic inline fun println(message: Float): Unit defined in kotlin.io\npublic inline fun println(message: Int): Unit defined in kotlin.io\npublic inline fun println(message: Long): Unit defined in kotlin.io\npublic inline fun println(message: Short): Unit defined in kotlin.io", + "output_type": "error", + "traceback": [ + "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException: at Cell In[5], line 3, column 43: Unresolved reference: crypto", + "at Cell In[5], line 4, column 43: Unresolved reference: signers", + "at Cell In[5], line 5, column 43: Unresolved reference: signers", + "at Cell In[5], line 6, column 33: Unresolved reference: nip10Notes", + "at Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync", + "at Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent", + "at Cell In[5], line 12, column 1: Overload resolution ambiguity: ", + "public inline fun println(message: Any?): Unit defined in kotlin.io", + "public inline fun println(message: Boolean): Unit defined in kotlin.io", + "public inline fun println(message: Byte): Unit defined in kotlin.io", + "public inline fun println(message: Char): Unit defined in kotlin.io", + "public inline fun println(message: CharArray): Unit defined in kotlin.io", + "public inline fun println(message: Double): Unit defined in kotlin.io", + "public inline fun println(message: Float): Unit defined in kotlin.io", + "public inline fun println(message: Int): Unit defined in kotlin.io", + "public inline fun println(message: Long): Unit defined in kotlin.io", + "public inline fun println(message: Short): Unit defined in kotlin.io", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.JupyterCompilerImpl.compileSync(JupyterCompilerImpl.kt:208)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.InternalEvaluatorImpl.eval(InternalEvaluatorImpl.kt:126)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:80)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withHost(ReplForJupyterImpl.kt:791)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl.execute-L4Nmkdk(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.execution.CellExecutor$DefaultImpls.execute-L4Nmkdk$default(CellExecutor.kt:13)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evaluateUserCode-wNURfNM(ReplForJupyterImpl.kt:613)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalExImpl(ReplForJupyterImpl.kt:471)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.access$evalExImpl(ReplForJupyterImpl.kt:143)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:464)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:463)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withEvalContext(ReplForJupyterImpl.kt:444)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalEx(ReplForJupyterImpl.kt:463)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:159)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedIn(IdeCompatibleMessageRequestProcessor.kt:335)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedIn(IdeCompatibleMessageRequestProcessor.kt:54)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:349)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedErr(IdeCompatibleMessageRequestProcessor.kt:324)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedErr(IdeCompatibleMessageRequestProcessor.kt:54)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1.invoke(IdeCompatibleMessageRequestProcessor.kt:348)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedOut(IdeCompatibleMessageRequestProcessor.kt:316)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.evalWithIO(IdeCompatibleMessageRequestProcessor.kt:347)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:157)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$Task.execute(JupyterExecutorImpl.kt:41)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:83)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:80)", + "\tat kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)", + "" + ] + } + ], + "execution_count": 5 + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Kotlin", + "language": "kotlin", + "name": "kotlin" + }, + "language_info": { + "name": "kotlin", + "version": "1.9.23", + "mimetype": "text/x-kotlin", + "file_extension": ".kt", + "pygments_lexer": "kotlin", + "codemirror_mode": "text/x-kotlin", + "nbconvert_exporter": "" + }, + "ktnbPluginMetadata": { + "projectDependencies": [ + "Amethyst.amethyst.unitTest" + ], + "projectLibraries": false + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index 8882836bbd..b40314fd7d 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue @@ -42,7 +42,7 @@ class LargeDBSignatureCheck { val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json") val eventArray = - EventMapper.mapper.readValue>( + JsonMapper.mapper.readValue>( InputStreamReader(fullDBInputStream), ) as List @@ -62,7 +62,7 @@ class LargeDBSignatureCheck { val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") val eventArray = - EventMapper.mapper.readValue>( + JsonMapper.mapper.readValue>( GZIPInputStream(fullDBInputStream), ) as List diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt index b16fc94a28..23a80683bf 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import org.junit.Test import org.junit.runner.RunWith @@ -52,8 +52,8 @@ class EventSigCheck { @Test fun testUnicode2028and2029ShouldNotBeEscaped() { - val msg = EventMapper.mapper.readTree(payload1) - val event = EventMapper.fromJson(msg[2]) + val msg = JsonMapper.mapper.readTree(payload1) + val event = JsonMapper.fromJson(msg[2]) // Should pass event.checkSignature() diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt new file mode 100644 index 0000000000..628f24c343 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class AddressableTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, "test.db", relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testReplacingAddressables() { + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(version1) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + } + + @Test + fun testBlockingOldAddressables() { + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt new file mode 100644 index 0000000000..6dee855808 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import junit.framework.TestCase + +fun EventStore.assertQuery( + expected: Event?, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + if (expected == null) { + TestCase.assertEquals(0, queryResult.size) + TestCase.assertEquals(0, countResult) + } else { + TestCase.assertEquals(1, queryResult.size) + TestCase.assertEquals(1, countResult) + TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + } +} + +fun EventStore.assertQuery( + expected: List, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + TestCase.assertEquals(expected.size, queryResult.size) + TestCase.assertEquals(expected.size, countResult) + expected.forEachIndexed { index, event -> + TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + } +} + +fun SQLiteEventStore.assertQuery( + expected: Event?, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + if (expected == null) { + TestCase.assertEquals(0, queryResult.size) + TestCase.assertEquals(0, countResult) + } else { + TestCase.assertEquals(1, queryResult.size) + TestCase.assertEquals(1, countResult) + TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + } +} + +fun SQLiteEventStore.assertQuery( + expected: List, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + TestCase.assertEquals(expected.size, queryResult.size) + TestCase.assertEquals(expected.size, countResult) + expected.forEachIndexed { index, event -> + TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt new file mode 100644 index 0000000000..8aa2a7f3be --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -0,0 +1,207 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import junit.framework.TestCase.assertEquals +import org.junit.After +import org.junit.Before +import org.junit.Test + +class BasicTest { + private lateinit var db: SQLiteEventStore + + val signer = NostrSignerSync() + + companion object Companion { + val profile = + MetadataEvent( + id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + createdAt = 1740669816, + tags = + arrayOf( + arrayOf("alt", "User profile for Vitor"), + arrayOf("name", "Vitor"), + ), + content = "{\"name\":\"Vitor\"}", + sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4", + ) + + val comment = + CommentEvent( + id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + createdAt = 1747753115, + tags = + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + content = "testing", + sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + } + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = SQLiteEventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertDeleteEvent() { + val note = signer.sign(TextNoteEvent.build("test1")) + + db.insertEvent(note) + + db.assertQuery(note, Filter(ids = listOf(note.id))) + + db.delete(note.id) + + db.assertQuery(null, Filter(ids = listOf(note.id))) + + db.insertEvent(note) + + db.assertQuery(note, Filter(ids = listOf(note.id))) + } + + @Test + fun testEmptyFilter() { + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) + + db.insertEvent(note1) + + db.assertQuery(note1, Filter()) + + db.insertEvent(note2) + + db.assertQuery(listOf(note2, note1), Filter()) + } + + @Test + fun testLimitFilter() { + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3)) + val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4)) + + db.insertEvent(note1) + + db.assertQuery(note1, Filter(limit = 1)) + + db.insertEvent(note2) + db.insertEvent(note3) + db.insertEvent(note4) + + db.assertQuery(listOf(note4), Filter(limit = 1)) + } + + @Test + fun testPubkeyTag() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery( + comment, + Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), + ) + } + + @Test + fun testTagOnly() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) + } + + @Test + fun testTagWithSinceOnly() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), + ) + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), + ) + db.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnly() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), + ) + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), + ) + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnlyEmitting() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> + assertEquals(comment.toJson(), event.toJson()) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt new file mode 100644 index 0000000000..fde0d95665 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -0,0 +1,173 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class DeletionTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertDeleteEvent() { + val note1 = signer.sign(TextNoteEvent.build("test1")) + val note2 = signer.sign(TextNoteEvent.build("test2")) + val note3 = signer.sign(TextNoteEvent.build("test3")) + + db.insert(note1) + db.insert(note2) + db.insert(note3) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event for this event id exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + } + + @Test + fun testInsertDeleteEventOfAddressable() { + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(note1) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + + db.insert(note2) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event for this event id exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + } + + @Test + fun testInsertDeleteEventOfAddressable2() { + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(note1) + db.insert(note2) + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event for this address exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt new file mode 100644 index 0000000000..9194df913b --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.assertTrue +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ExpirationTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testDeletingExpiredEvents() { + val time = TimeUtils.now() + + val noteSafe = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 100) + }, + ) + + db.insert(noteSafe) + + val noteToExpire = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 1) + }, + ) + + db.insert(noteToExpire) + + db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) + + Thread.sleep(2000) + + db.deleteExpiredEvents() + + db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) + } + + @Test + fun testInsertingExpiredEvents() { + val time = TimeUtils.now() + + val note1 = + signer.sign( + TextNoteEvent.build("test1", createdAt = time - 12) { + expiration(time - 10) + }, + ) + + try { + db.insert(note1) + fail("Should not be able to insert expired events") + } catch (e: Exception) { + assertTrue(e is SQLiteConstraintException) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt new file mode 100644 index 0000000000..ac62d9ff33 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.zip.GZIPInputStream +import kotlin.system.measureTimeMillis + +@RunWith(AndroidJUnit4::class) +class LargeDBTests { + companion object { + fun getEventDB(): List { + // This file includes duplicates + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") + + return JsonMapper.mapper.readValue>( + GZIPInputStream(fullDBInputStream), + ) + } + + val events = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + } + + private lateinit var db: EventStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, "largeDBTest.db") + db.store.clearDB() + } + + @After + fun tearDown() { + db.store.clearDB() + db.close() + } + + @Test + fun insertHeavyEvent() { + events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> + println(event.toJson()) + try { + val measure = + measureTimeMillis { + db.insert(event) + } + if (measure > 1) { + println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") + } + } catch (e: SQLiteException) { + Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") + } + } + } + + @Test + fun insertDatabase() { + events.forEach { event -> + try { + val measure = + measureTimeMillis { + db.insert(event) + } + if (measure > 1) { + println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") + } + } catch (e: SQLiteException) { + Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") + } + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt new file mode 100644 index 0000000000..0a85cfc75c --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ReplaceableTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testReplacing() { + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + + db.insert(version1) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + } + + @Test + fun testBlockingOldVersions() { + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt new file mode 100644 index 0000000000..578d6aeb76 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class RightToVanishTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertDeleteEvent() { + val time = TimeUtils.now() + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) + + db.insert(note1) + db.insert(note2) + db.insert(note3) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val vanish = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt new file mode 100644 index 0000000000..f73a3372d0 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import org.junit.After +import org.junit.Before +import org.junit.Test + +class SearchTest { + private lateinit var db: SQLiteEventStore + + companion object Companion { + val profile = + MetadataEvent( + id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + createdAt = 1740669816, + tags = + arrayOf( + arrayOf("alt", "User profile for Vitor"), + arrayOf("name", "Vitor"), + ), + content = "{\"name\":\"Vitor\"}", + sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4", + ) + + val comment = + CommentEvent( + id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + createdAt = 1747753115, + tags = + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + content = "testing", + sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + } + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = SQLiteEventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testTagWithSearch() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery(null, Filter(search = "testing1")) + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing")) + + db.delete(comment.id) + + db.assertQuery(null, Filter(search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + + db.insertEvent(comment) + + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt index 66c0d41d51..0cbd18b877 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import java.util.concurrent.CountDownLatch @@ -51,14 +52,14 @@ class OtsTest { @Test fun verifyNostrEvent2() { - val ots = Event.Companion.fromJson(otsEvent2) as OtsEvent + val ots = Event.fromJson(otsEvent2) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(1706322179L, ots.verify(resolver)) } @Test fun verifyNostrPendingEvent() { - val ots = Event.Companion.fromJson(otsPendingEvent) as OtsEvent + val ots = Event.fromJson(otsPendingEvent) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(null, ots.verify(resolver)) @@ -74,17 +75,13 @@ class OtsTest { val signer = NostrSignerInternal(KeyPair()) - var newOts: OtsEvent? = null val countDownLatch = CountDownLatch(1) - signer.sign(OtsEvent.build(eventId, upgraded!!)) { - newOts = it - countDownLatch.countDown() - } + val newOts = runBlocking { signer.sign(OtsEvent.build(eventId, upgraded!!)) } Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - println(newOts!!.toJson()) + println(newOts.toJson()) println(resolver.info(newOts.otsByteArray())) assertEquals(1708879025L, newOts.verify(resolver)) @@ -93,18 +90,17 @@ class OtsTest { @Test fun createOTSEventAndVerify() { val signer = NostrSignerInternal(KeyPair()) - var ots: OtsEvent? = null val countDownLatch = CountDownLatch(1) - signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver))) { - ots = it - countDownLatch.countDown() - } + val ots = + runBlocking { + signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver))) + } Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - println(ots!!.toJson()) + println(ots.toJson()) println(resolver.info(ots.otsByteArray())) assertEquals(null, ots.verify(resolver)) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt index b5e4efefa9..3e10f561dc 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag @@ -186,7 +187,7 @@ class ThreadingTests { val taggedUsers = listOf( - PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0", "wss://goiaba.com"), + PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0", RelayUrlNormalizer.normalize("wss://goiaba.com")), PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), PTag("77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), ) @@ -249,7 +250,7 @@ class ThreadingTests { val taggedUsers = listOf( - PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", RelayUrlNormalizer.normalize("wss://banana.com")), ) assertEquals(taggedUsers, note.taggedUsers()) @@ -302,7 +303,7 @@ class ThreadingTests { val taggedUsers = listOf( - PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", RelayUrlNormalizer.normalize("wss://banana.com")), ) assertEquals(taggedUsers, note.taggedUsers()) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt index cca3b5c4a9..347ecf81f9 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip19Bech32 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -33,6 +32,7 @@ import com.vitorpamplona.quartz.utils.Hex import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -48,18 +48,12 @@ class NIP19EmbedTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var textNote: Event? = null - - val countDownLatch = CountDownLatch(1) - - signer.sign( - TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), - ) { - textNote = it - countDownLatch.countDown() - } - - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val textNote: TextNoteEvent = + runBlocking { + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) + } assertNotNull(textNote) @@ -81,29 +75,23 @@ class NIP19EmbedTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var eyeglassesPrescriptionEvent: Event? = null - - val countDownLatch = CountDownLatch(1) - - signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) { - eyeglassesPrescriptionEvent = it - countDownLatch.countDown() - } - - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val eyeglassesPrescriptionEvent = + runBlocking { + signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) + } assertNotNull(eyeglassesPrescriptionEvent) - val bech32 = NEmbed.create(eyeglassesPrescriptionEvent!!) + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) - println(eyeglassesPrescriptionEvent!!.toJson()) + println(eyeglassesPrescriptionEvent.toJson()) println(bech32) val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event assertTrue(decodedNote.verify()) - assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) } @Test @@ -113,29 +101,27 @@ class NIP19EmbedTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var eyeglassesPrescriptionEvent: Event? = null - val countDownLatch = CountDownLatch(1) - signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) { - eyeglassesPrescriptionEvent = it - countDownLatch.countDown() - } + val eyeglassesPrescriptionEvent = + runBlocking { + signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) + } Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) assertNotNull(eyeglassesPrescriptionEvent) - val bech32 = NEmbed.create(eyeglassesPrescriptionEvent!!) + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) - println(eyeglassesPrescriptionEvent!!.toJson()) + println(eyeglassesPrescriptionEvent.toJson()) println(bech32) val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event assertTrue(decodedNote.verify()) - assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) } @Test @@ -145,29 +131,23 @@ class NIP19EmbedTests { KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), ) - var eyeglassesPrescriptionEvent: Event? = null - - val countDownLatch = CountDownLatch(1) - - signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) { - eyeglassesPrescriptionEvent = it - countDownLatch.countDown() - } - - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val eyeglassesPrescriptionEvent = + runBlocking { + signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) + } assertNotNull(eyeglassesPrescriptionEvent) - val bech32 = NEmbed.create(eyeglassesPrescriptionEvent!!) + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) - println(eyeglassesPrescriptionEvent!!.toJson()) + println(eyeglassesPrescriptionEvent.toJson()) println(bech32) val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event assertTrue(decodedNote.verify()) - assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) } @Test diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt index 8346e3b7e4..6f0707638d 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 286a6ce0d8..61794b0964 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -23,14 +23,16 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch @RunWith(AndroidJUnit4::class) internal class Nip46Test { @@ -39,216 +41,222 @@ internal class Nip46Test { val peer = NostrSignerInternal(KeyPair()) val dummyEvent = - Event( + EventTemplate( + createdAt = TimeUtils.now(), + kind = 1, + tags = emptyArray(), + content = "test", + ) + + val dummyEventSigned = + TextNoteEvent( id = "", pubKey = "", createdAt = TimeUtils.now(), - kind = 1, tags = emptyArray(), content = "test", sig = "", ) - fun encodeDecodeEvent(req: T): T { - var countDownLatch = CountDownLatch(1) - var eventStr: String? = null + suspend fun encodeDecodeEvent(req: T): T { + val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson() - NostrConnectEvent.create(req, remoteKey.pubKey, signer) { - eventStr = it.toJson() - countDownLatch.countDown() + return (Event.fromJson(eventStr) as NostrConnectEvent).decryptMessage(signer) as T + } + + @Test + fun signEncoder() = + runBlocking { + val expected = BunkerRequestSign(event = dummyEvent) + val actual = encodeDecodeEvent(expected) + + assertEquals(BunkerRequestSign.METHOD_NAME, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(dummyEvent.createdAt, actual.event.createdAt) } - countDownLatch.await() + @Test + fun connectEncoder() = + runBlocking { + val expected = BunkerRequestConnect(remoteKey = remoteKey.pubKey) + val actual = encodeDecodeEvent(expected) - countDownLatch = CountDownLatch(1) - var innerMessage: T? = null - - (Event.fromJson(eventStr!!) as NostrConnectEvent).plainContent(signer) { - innerMessage = it as T - countDownLatch.countDown() + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) } - countDownLatch.await() + @Test + fun pingEncoder() = + runBlocking { + val expected = BunkerRequestPing() + val actual = encodeDecodeEvent(expected) - return innerMessage!! - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + } @Test - fun signEncoder() { - val expected = BunkerRequestSign(event = dummyEvent) - val actual = encodeDecodeEvent(expected) + fun getPubkeyEncoder() = + runBlocking { + val expected = BunkerRequestGetPublicKey() + val actual = encodeDecodeEvent(expected) - assertEquals(BunkerRequestSign.METHOD_NAME, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(dummyEvent.id, actual.event.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + } @Test - fun connectEncoder() { - val expected = BunkerRequestConnect(remoteKey = remoteKey.pubKey) - val actual = encodeDecodeEvent(expected) + fun getRelaysEncoder() = + runBlocking { + val expected = BunkerRequestGetRelays() + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + } @Test - fun pingEncoder() { - val expected = BunkerRequestPing() - val actual = encodeDecodeEvent(expected) + fun testNip04Encrypt() = + runBlocking { + val expected = BunkerRequestNip04Encrypt(pubKey = peer.pubKey, message = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.message, actual.message) + } @Test - fun getPubkeyEncoder() { - val expected = BunkerRequestGetPublicKey() - val actual = encodeDecodeEvent(expected) + fun testNip44Encrypt() = + runBlocking { + val expected = BunkerRequestNip44Encrypt(pubKey = peer.pubKey, message = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.message, actual.message) + } @Test - fun getRelaysEncoder() { - val expected = BunkerRequestGetRelays() - val actual = encodeDecodeEvent(expected) + fun testNip04Decrypt() = + runBlocking { + val expected = BunkerRequestNip04Decrypt(pubKey = peer.pubKey, ciphertext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.ciphertext, actual.ciphertext) + } @Test - fun testNip04Encrypt() { - val expected = BunkerRequestNip04Encrypt(pubKey = peer.pubKey, message = "Test") - val actual = encodeDecodeEvent(expected) + fun testNip44Decrypt() = + runBlocking { + val expected = BunkerRequestNip44Decrypt(pubKey = peer.pubKey, ciphertext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.message, actual.message) - } - - @Test - fun testNip44Encrypt() { - val expected = BunkerRequestNip44Encrypt(pubKey = peer.pubKey, message = "Test") - val actual = encodeDecodeEvent(expected) - - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.message, actual.message) - } - - @Test - fun testNip04Decrypt() { - val expected = BunkerRequestNip04Decrypt(pubKey = peer.pubKey, ciphertext = "Test") - val actual = encodeDecodeEvent(expected) - - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.ciphertext, actual.ciphertext) - } - - @Test - fun testNip44Decrypt() { - val expected = BunkerRequestNip44Decrypt(pubKey = peer.pubKey, ciphertext = "Test") - val actual = encodeDecodeEvent(expected) - - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.ciphertext, actual.ciphertext) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.ciphertext, actual.ciphertext) + } // Responses @Test - fun testAckResponse() { - val expected = BunkerResponseAck() - val actual = encodeDecodeEvent(expected) + fun testAckResponse() = + runBlocking { + val expected = BunkerResponseAck() + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + } @Test - fun testPongResponse() { - val expected = BunkerResponsePong() - val actual = encodeDecodeEvent(expected) + fun testPongResponse() = + runBlocking { + val expected = BunkerResponsePong() + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + } @Test - fun testErrorResponse() { - val expected = BunkerResponseError(error = "Error") - val actual = encodeDecodeEvent(expected) + fun testErrorResponse() = + runBlocking { + val expected = BunkerResponseError(error = "Error") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + } @Test - fun testEventResponse() { - val expected = BunkerResponseEvent(event = dummyEvent) - val actual = encodeDecodeEvent(expected) + fun testEventResponse() = + runBlocking { + val expected = BunkerResponseEvent(event = dummyEventSigned) + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(dummyEvent.id, actual.event.id) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(dummyEventSigned.id, actual.event.id) + } @Test - fun testPubkeyResponse() { - val expected = BunkerResponsePublicKey(pubkey = peer.pubKey) - val actual = encodeDecodeEvent(expected) + fun testPubkeyResponse() = + runBlocking { + val expected = BunkerResponsePublicKey(pubkey = peer.pubKey) + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.pubkey, actual.pubkey) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.pubkey, actual.pubkey) + } @Test - fun testRelaysResponse() { - val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false))) - val actual = encodeDecodeEvent(expected) + fun testRelaysResponse() = + runBlocking { + val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false))) + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.relays["url"], actual.relays["url"]) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.relays["url"], actual.relays["url"]) + } @Test @Ignore("Impossible to recreate the class since there are no hints on the json") - fun testDecryptResponse() { - val expected = BunkerResponseDecrypt(plaintext = "Test") - val actual = encodeDecodeEvent(expected) + fun testDecryptResponse() = + runBlocking { + val expected = BunkerResponseDecrypt(plaintext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.plaintext, actual.plaintext) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.plaintext, actual.plaintext) + } @Test @Ignore("Impossible to recreate the class since there are no hints on the json") - fun testEncryptResponse() { - val expected = BunkerResponseEncrypt(ciphertext = "Test") - val actual = encodeDecodeEvent(expected) + fun testEncryptResponse() = + runBlocking { + val expected = BunkerResponseEncrypt(ciphertext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.ciphertext, actual.ciphertext) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.ciphertext, actual.ciphertext) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt index e7da75dd8d..35f720ff30 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt @@ -24,13 +24,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEncryption.Companion.createEncryptionPrivateKey -import com.vitorpamplona.quartz.nip59GiftWraps.wait1SecondForResult import com.vitorpamplona.quartz.utils.Hex import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail +import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @@ -39,7 +40,7 @@ class PrivateZapTests { @Test fun testPollZap() { val poll = - EventMapper.fromJson( + JsonMapper.fromJson( """{ "content": "New poll \n\n #zappoll", "created_at": 1682440713, @@ -89,42 +90,35 @@ class PrivateZapTests { ), ) - var resultPrivateZap: Event? = null - - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = poll, - relays = setOf("wss://relay.damus.io/"), - signer = loggedIn, - pollOption = 0, - message = "", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null, - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() - - if (recepientPK != null && recepientPost != null) { - val privateKey = - createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt, - ) - val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - - println(decodedPrivateZap?.toJson()) - - resultPrivateZap = decodedPrivateZap - - onDone() - } else { - fail("Should not be null") - } + val privateZapRequest = + runBlocking { + LnZapRequestEvent.create( + zappedEvent = poll, + relays = setOf(RelayUrlNormalizer.normalize("wss://relay.damus.io/")), + signer = loggedIn, + pollOption = 0, + message = "", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) } - } - assertNotNull(resultPrivateZap) + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = PrivateZapRequestBuilder().decryptAnonTag(privateZapRequest.getAnonTag(), privateKey, recepientPK) + + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } } @Test @@ -156,41 +150,39 @@ class PrivateZapTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var resultPrivateZap: Event? = null - - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = textNote, - relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), - signer = loggedIn, - pollOption = null, - message = "test", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null, - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() - - if (recepientPK != null && recepientPost != null) { - val privateKey = - createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt, - ) - val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - - println(decodedPrivateZap?.toJson()) - - resultPrivateZap = decodedPrivateZap - - onDone() - } else { - fail("Should not be null") - } + val privateZapRequest = + runBlocking { + LnZapRequestEvent.create( + zappedEvent = textNote, + relays = + setOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io/"), + RelayUrlNormalizer.normalize("wss://relay.damus2.io/"), + RelayUrlNormalizer.normalize("wss://relay.damus3.io/"), + ), + signer = loggedIn, + pollOption = null, + message = "test", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) } - } - assertNotNull(resultPrivateZap) + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = PrivateZapRequestBuilder().decryptAnonTag(privateZapRequest.getAnonTag(), privateKey, recepientPK) + + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt index d37391915a..1b244a2b30 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull @@ -41,104 +42,87 @@ import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class GiftWrapEventTest { @Test() - fun testNip17Utils() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + fun testNip17Utils() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - // Requires 3 tests - val countDownLatch = CountDownLatch(3) - - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - listOf(PTag(receiver.pubKey, null)), - ), - sender, - ) { events -> - countDownLatch.countDown() + val events = + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey, null)), + ), + sender, + ) // Simulate Receiver val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } eventsReceiverGets.forEach { - it.unwrap(receiver) { event -> - if (event is SealedRumorEvent) { - event.unseal(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(receiver) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(receiver) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } // Simulate Sender val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } eventsSenderGets.forEach { - it.unwrap(sender) { event -> - if (event is SealedRumorEvent) { - event.unseal(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(sender) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(sender) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } - @Test() - fun testNip17UtilsForGroups() { - val sender = NostrSignerInternal(KeyPair()) - val receiver1 = NostrSignerInternal(KeyPair()) - val receiver2 = NostrSignerInternal(KeyPair()) - val receiver3 = NostrSignerInternal(KeyPair()) - val receiver4 = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + fun testNip17UtilsForGroups() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiver1 = NostrSignerInternal(KeyPair()) + val receiver2 = NostrSignerInternal(KeyPair()) + val receiver3 = NostrSignerInternal(KeyPair()) + val receiver4 = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - val receivers = - listOf( - receiver1, - receiver2, - receiver3, - receiver4, - ) + val receivers = + listOf( + receiver1, + receiver2, + receiver3, + receiver4, + ) - val countDownLatch = CountDownLatch(receivers.size + 2) - - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - receivers.map { PTag(it.pubKey, null) }, - ), - sender, - ) { events -> - countDownLatch.countDown() + val events = + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + receivers.map { PTag(it.pubKey, null) }, + ), + sender, + ) // Simulate Receiver receivers.forEach { receiver -> val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } eventsReceiverGets.forEach { - it.unwrap(receiver) { event -> - if (event is SealedRumorEvent) { - event.unseal(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(receiver) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(receiver) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } } @@ -146,149 +130,126 @@ class GiftWrapEventTest { // Simulate Sender val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } eventsSenderGets.forEach { - it.unwrap(sender) { event -> - if (event is SealedRumorEvent) { - event.unseal(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(sender) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(sender) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } - @Test() - fun testInternalsSimpleMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + fun testInternalsSimpleMessage() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(2) - - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiver: GiftWrapEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi There!", - to = listOf(PTag(receiver.pubKey, null)), - ), - ) { senderMessage -> + val senderMessage = + sender.sign( + ChatMessageEvent.build( + msg = "Hi There!", + to = listOf(PTag(receiver.pubKey, null)), + ), + ) // MsgFor the Receiver + val encMsgFromSenderToReceiver = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = receiver.pubKey, + signer = sender, + ) - SealedRumorEvent.create( - event = senderMessage, - encryptTo = receiver.pubKey, - signer = sender, - ) { encMsgFromSenderToReceiver -> - // Should expose sender - assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) + // Should expose sender + assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) + val giftWrapToReceiver = GiftWrapEvent.create( event = encMsgFromSenderToReceiver, recipientPubKey = receiver.pubKey, - ) { giftWrapToReceiver -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) - assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) + ) - // Should not include sender as recipient - assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) + assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) - giftWrapEventToReceiver = giftWrapToReceiver - - countDownLatch.countDown() - } - } + // Should be addressed to the receiver + assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) // MsgFor the Sender - SealedRumorEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender, - ) { encMsgFromSenderToSender -> - // Should expose sender - assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + val encMsgFromSenderToSender = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) + // Should expose sender + assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + + val giftWrapToSender = GiftWrapEvent.create( event = encMsgFromSenderToSender, recipientPubKey = sender.pubKey, - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) + ) - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) - giftWrapEventToSender = giftWrapToSender + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - countDownLatch.countDown() - } - } - } + // Done + // ----- + // Start receiving - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + // Receiver's side + // Makes sure it can only be decrypted by the target user - // Receiver's side - // Makes sure it can only be decrypted by the target user + assertNotNull(giftWrapToSender) + assertNotNull(giftWrapToReceiver) - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiver) - - val countDownDecryptLatch = CountDownLatch(2) - - giftWrapEventToSender!!.unwrap(sender) { unwrappedMsgForSenderBySender -> + val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind) assertTrue(unwrappedMsgForSenderBySender is SealedRumorEvent) if (unwrappedMsgForSenderBySender is SealedRumorEvent) { - unwrappedMsgForSenderBySender.unseal(sender) { unwrappedRumorToSenderBySender -> - assertEquals("Hi There!", unwrappedRumorToSenderBySender.content) - countDownDecryptLatch.countDown() - } + val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender) + assertEquals("Hi There!", unwrappedRumorToSenderBySender.content) - unwrappedMsgForSenderBySender.unseal(receiver) { _ -> - fail( - "Should not be able to decrypt msg for the sender by the sender but decrypted with receiver", - ) + unwrappedMsgForSenderBySender.unsealOrNull(receiver)?.let { _ -> + fail("Should not be able to decrypt msg for the sender by the sender but decrypted with receiver") } } - } - giftWrapEventToReceiver!!.unwrap(sender) { _ -> - fail("Should not be able to decrypt msg for the receiver decrypted by the sender") - } + giftWrapToReceiver.unwrapOrNull(sender)?.let { _ -> + fail("Should not be able to decrypt msg for the receiver decrypted by the sender") + } - giftWrapEventToSender!!.unwrap(receiver) { _ -> - fail("Should not be able to decrypt msg for the sender decrypted by the receiver") - } + giftWrapToReceiver.unwrapOrNull(receiver)?.let { _ -> + fail("Should not be able to decrypt msg for the sender decrypted by the receiver") + } - giftWrapEventToReceiver!!.unwrap(receiver) { unwrappedMsgForReceiverByReceiver -> + val unwrappedMsgForReceiverByReceiver = giftWrapToReceiver.unwrapThrowing(receiver) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) assertTrue(unwrappedMsgForReceiverByReceiver is SealedRumorEvent) if (unwrappedMsgForReceiverByReceiver is SealedRumorEvent) { - unwrappedMsgForReceiverByReceiver.unseal(receiver) { unwrappedRumorToReceiverByReceiver -> - assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver?.content) - countDownDecryptLatch.countDown() - } + val unwrappedRumorToReceiverByReceiver = unwrappedMsgForReceiverByReceiver.unsealThrowing(receiver) + assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver.content) - unwrappedMsgForReceiverByReceiver.unseal(sender) { unwrappedRumorToReceiverBySender -> + unwrappedMsgForReceiverByReceiver.unsealOrNull(sender)?.let { _ -> fail( "Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender", ) @@ -296,227 +257,194 @@ class GiftWrapEventTest { } } - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - @Test() - fun testInternalsGroupMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiverA = NostrSignerInternal(KeyPair()) - val receiverB = NostrSignerInternal(KeyPair()) + fun testInternalsGroupMessage() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiverA = NostrSignerInternal(KeyPair()) + val receiverB = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(3) + val senderMessage = + sender.sign( + ChatMessageEvent.build( + msg = "Who is going to the party tonight?", + to = listOf(PTag(receiverA.pubKey), PTag(receiverB.pubKey)), + ), + ) - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiverA: GiftWrapEvent? = null - var giftWrapEventToReceiverB: GiftWrapEvent? = null + val msgFromSenderToReceiverA = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = receiverA.pubKey, + signer = sender, + ) - sender.sign( - ChatMessageEvent.build( - msg = "Who is going to the party tonight?", - to = listOf(PTag(receiverA.pubKey), PTag(receiverB.pubKey)), - ), - ) { senderMessage -> - SealedRumorEvent.create( - event = senderMessage, - encryptTo = receiverA.pubKey, - signer = sender, - ) { msgFromSenderToReceiverA -> - // Should expose sender - assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) + // Should expose sender + assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) + val giftWrapForReceiverA = GiftWrapEvent.create( event = msgFromSenderToReceiverA, recipientPubKey = receiverA.pubKey, - ) { giftWrapForReceiverA -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) + ) - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) - giftWrapEventToReceiverA = giftWrapForReceiverA + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) - countDownLatch.countDown() - } - } + val msgFromSenderToReceiverB = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = receiverB.pubKey, + signer = sender, + ) - SealedRumorEvent.create( - event = senderMessage, - encryptTo = receiverB.pubKey, - signer = sender, - ) { msgFromSenderToReceiverB -> - // Should expose sender - assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) + // Should expose sender + assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) + val giftWrapForReceiverB = GiftWrapEvent.create( event = msgFromSenderToReceiverB, recipientPubKey = receiverB.pubKey, - ) { giftWrapForReceiverB -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) + ) - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) - giftWrapEventToReceiverB = giftWrapForReceiverB + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) - countDownLatch.countDown() - } - } + val msgFromSenderToSender = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) - SealedRumorEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender, - ) { msgFromSenderToSender -> - // Should expose sender - assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToSender.tags.isEmpty()) + // Should expose sender + assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToSender.tags.isEmpty()) + val giftWrapToSender = GiftWrapEvent.create( event = msgFromSenderToSender, recipientPubKey = sender.pubKey, - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) + ) - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) - giftWrapEventToSender = giftWrapToSender + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - countDownLatch.countDown() - } - } - } + // Done + // ----- + // Decrypting - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + // Receiver's side + // Makes sure it can only be decrypted by the target user - // Receiver's side - // Makes sure it can only be decrypted by the target user + assertNotNull(giftWrapToSender) + assertNotNull(giftWrapForReceiverA) + assertNotNull(giftWrapForReceiverB) - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiverA) - assertNotNull(giftWrapEventToReceiverB) - - val countDownDecryptLatch = CountDownLatch(3) - - giftWrapEventToSender?.unwrap(sender) { unwrappedMsgForSenderBySender -> + val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind) if (unwrappedMsgForSenderBySender is SealedRumorEvent) { - unwrappedMsgForSenderBySender.unseal(receiverA) { unwrappedRumorToSenderByReceiverA -> + unwrappedMsgForSenderBySender.unsealOrNull(receiverA)?.let { _ -> fail() } - unwrappedMsgForSenderBySender.unseal(receiverB) { unwrappedRumorToSenderByReceiverB -> + unwrappedMsgForSenderBySender.unsealOrNull(receiverB)?.let { _ -> fail() } - unwrappedMsgForSenderBySender.unseal(sender) { unwrappedRumorToSenderBySender -> - assertEquals( - "Who is going to the party tonight?", - unwrappedRumorToSenderBySender.content, - ) - } + val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender) + assertEquals("Who is going to the party tonight?", unwrappedRumorToSenderBySender.content) } - countDownDecryptLatch.countDown() - } + giftWrapForReceiverA.unwrapOrNull(sender)?.let { unwrappedMsgForReceiverBySenderA -> + fail("Should not be able to decode msg to the receiver A with the sender's key") + } - giftWrapEventToReceiverA!!.unwrap(sender) { unwrappedMsgForReceiverBySenderA -> - fail("Should not be able to decode msg to the receiver A with the sender's key") - } + giftWrapForReceiverB.unwrapOrNull(sender)?.let { unwrappedMsgForReceiverBySenderB -> + fail("Should not be able to decode msg to the receiver B with the sender's key") + } - giftWrapEventToReceiverB!!.unwrap(sender) { unwrappedMsgForReceiverBySenderB -> - fail("Should not be able to decode msg to the receiver B with the sender's key") - } + giftWrapToSender.unwrapOrNull(receiverA)?.let { + fail("Should not be able to decode msg to sender with the receiver A's key") + } - giftWrapEventToSender!!.unwrap(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } - - giftWrapEventToReceiverA!!.unwrap(receiverA) { unwrappedMsgForReceiverAByReceiverA -> + val unwrappedMsgForReceiverAByReceiverA = giftWrapForReceiverA.unwrapThrowing(receiverA) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) if (unwrappedMsgForReceiverAByReceiverA is SealedRumorEvent) { - unwrappedMsgForReceiverAByReceiverA.unseal(receiverA) { unwrappedRumorToReceiverAByReceiverA -> - assertEquals( - "Who is going to the party tonight?", - unwrappedRumorToReceiverAByReceiverA.content, - ) - } + val unwrappedRumorToReceiverAByReceiverA = unwrappedMsgForReceiverAByReceiverA.unsealThrowing(receiverA) + assertEquals("Who is going to the party tonight?", unwrappedRumorToReceiverAByReceiverA.content) - unwrappedMsgForReceiverAByReceiverA.unseal(sender) { unwrappedRumorToReceiverABySender -> + unwrappedMsgForReceiverAByReceiverA.unsealOrNull(sender)?.let { unwrappedRumorToReceiverABySender -> fail() } - unwrappedMsgForReceiverAByReceiverA.unseal(receiverB) { unwrappedRumorToReceiverAByReceiverB -> + unwrappedMsgForReceiverAByReceiverA.unsealOrNull(receiverB)?.let { unwrappedRumorToReceiverAByReceiverB -> fail() } } - countDownDecryptLatch.countDown() - } + giftWrapForReceiverB.unwrapOrNull(receiverA)?.let { + fail("Should not be able to decode msg to sender with the receiver A's key") + } - giftWrapEventToReceiverB!!.unwrap(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } + giftWrapToSender.unwrapOrNull(receiverB)?.let { unwrappedMsgForSenderByReceiverB -> + fail("Should not be able to decode msg to sender with the receiver B's key") + } + giftWrapForReceiverA.unwrapOrNull(receiverB)?.let { unwrappedMsgForReceiverAByReceiverB -> + fail("Should not be able to decode msg to receiver A with the receiver B's key") + } - giftWrapEventToSender!!.unwrap(receiverB) { unwrappedMsgForSenderByReceiverB -> - fail("Should not be able to decode msg to sender with the receiver B's key") - } - giftWrapEventToReceiverA!!.unwrap(receiverB) { unwrappedMsgForReceiverAByReceiverB -> - fail("Should not be able to decode msg to receiver A with the receiver B's key") - } - giftWrapEventToReceiverB!!.unwrap(receiverB) { unwrappedMsgForReceiverBByReceiverB -> + val unwrappedMsgForReceiverBByReceiverB = giftWrapForReceiverB.unwrapThrowing(receiverB) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) if (unwrappedMsgForReceiverBByReceiverB is SealedRumorEvent) { - unwrappedMsgForReceiverBByReceiverB.unseal(receiverA) { unwrappedRumorToReceiverBByReceiverA -> + unwrappedMsgForReceiverBByReceiverB.unsealOrNull(receiverA)?.let { unwrappedRumorToReceiverBByReceiverA -> fail() } - unwrappedMsgForReceiverBByReceiverB.unseal(receiverB) { unwrappedRumorToReceiverBByReceiverB -> - assertEquals( - "Who is going to the party tonight?", - unwrappedRumorToReceiverBByReceiverB.content, - ) + val unwrappedRumorToReceiverBByReceiverB = unwrappedMsgForReceiverBByReceiverB.unsealThrowing(receiverB) + assertEquals( + "Who is going to the party tonight?", + unwrappedRumorToReceiverBByReceiverB.content, + ) - countDownDecryptLatch.countDown() - } - - unwrappedMsgForReceiverBByReceiverB.unseal(sender) { unwrappedRumorToReceiverBBySender -> + unwrappedMsgForReceiverBByReceiverB.unsealOrNull(sender)?.let { unwrappedRumorToReceiverBBySender -> fail() } } } - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - @Test fun testCaseFromAmethyst1() { val json = @@ -537,18 +465,14 @@ class GiftWrapEventTest { } """.trimIndent() - var rumor: Event? = null - - wait1SecondForResult { onDone -> - val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor: Event = + runBlocking { + val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" + unwrapUnsealRumor(json, privateKey) } - } assertNotNull(rumor) - assertEquals("Hola, que tal?", rumor?.content) + assertEquals("Hola, que tal?", rumor.content) } @Test @@ -573,17 +497,13 @@ class GiftWrapEventTest { val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533" - var rumor: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor: Event = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } assertNotNull(rumor) - assertEquals("Hola, que tal?", rumor?.content) + assertEquals("Hola, que tal?", rumor.content) } @Test @@ -610,17 +530,14 @@ class GiftWrapEventTest { """.trimIndent() val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var rumor: Event? = null - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor: Event = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } assertNotNull(rumor) - assertEquals("test", rumor?.content) + assertEquals("test", rumor.content) } @Test @@ -645,21 +562,17 @@ class GiftWrapEventTest { val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var rumor: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } - assertEquals("asdfasdfasdf", rumor?.content) - assertEquals(1690659269L, rumor?.createdAt) - assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", rumor?.id) - assertEquals(14, rumor?.kind) - assertEquals("subject", rumor?.tags?.firstOrNull()?.get(0)) - assertEquals("test", rumor?.tags?.firstOrNull()?.get(1)) + assertEquals("asdfasdfasdf", rumor.content) + assertEquals(1690659269L, rumor.createdAt) + assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", rumor.id) + assertEquals(14, rumor.kind) + assertEquals("subject", rumor.tags.firstOrNull()?.get(0)) + assertEquals("test", rumor.tags.firstOrNull()?.get(1)) } @Test @@ -684,33 +597,28 @@ class GiftWrapEventTest { val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700" - var rumor: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } - assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", rumor?.id) - assertEquals("Howdy", rumor?.content) - assertEquals(1690833960L, rumor?.createdAt) - assertEquals(14, rumor?.kind) - assertEquals("p", rumor?.tags?.firstOrNull()?.get(0)) + assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", rumor.id) + assertEquals("Howdy", rumor.content) + assertEquals(1690833960L, rumor.createdAt) + assertEquals(14, rumor.kind) + assertEquals("p", rumor.tags.firstOrNull()?.get(0)) assertEquals( "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", - rumor?.tags?.firstOrNull()?.get(1), + rumor.tags.firstOrNull()?.get(1), ) - assertEquals("subject", rumor?.tags?.getOrNull(1)?.get(0)) - assertEquals("Stuff", rumor?.tags?.getOrNull(1)?.get(1)) + assertEquals("subject", rumor.tags.getOrNull(1)?.get(0)) + assertEquals("Stuff", rumor.tags.getOrNull(1)?.get(1)) } - fun unwrapUnsealRumor( + suspend fun unwrapUnsealRumor( json: String, privateKey: HexKey, - onReady: (Event) -> Unit, - ) { + ): Event { val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray())) val wrap = Event.fromJson(json) as GiftWrapEvent @@ -718,13 +626,13 @@ class GiftWrapEventTest { assertEquals(pkBytes.pubKey, wrap.recipientPubKey()) - wrap.unwrap(pkBytes) { event -> - if (event is SealedRumorEvent) { - event.unseal(pkBytes, onReady) - } else { - println(event.toJson()) - fail("Event is not a Sealed Rumor") - } + val event = wrap.unwrapThrowing(pkBytes) + return if (event is SealedRumorEvent) { + event.unsealThrowing(pkBytes) + } else { + println(event.toJson()) + fail("Event is not a Sealed Rumor") + throw Exception("Event is not a Sealed Rumor") } } @@ -750,23 +658,11 @@ class GiftWrapEventTest { val wrap = Event.fromJson(msg) as GiftWrapEvent wrap.checkSignature() - var event: Event? = null - - wait1SecondForResult { onDone -> - wrap.unwrap(receiversPrivateKey) { - event = it - onDone() + val event = + runBlocking { + wrap.unwrapThrowing(receiversPrivateKey) } - } assertNotNull(event) } } - -fun wait1SecondForResult(run: (onDone: () -> Unit) -> Unit) { - val countDownLatch = CountDownLatch(1) - - run { countDownLatch.countDown() } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 17540a0507..4477dfe7d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.quartz -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -36,6 +33,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -70,18 +68,26 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent @@ -98,13 +104,14 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -119,20 +126,35 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +interface EventBuilder { + fun build( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, + ): Event +} class EventFactory { companion object { - val factories: MutableMap>, String, HexKey) -> Event> = mutableMapOf() + val factories: MutableMap = mutableMapOf() - fun create( - id: String, - pubKey: String, + fun create( + id: HexKey, + pubKey: HexKey, createdAt: Long, kind: Int, tags: Array>, content: String, - sig: String, - ): Event = + sig: HexKey, + ): T = when (kind) { AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) @@ -143,8 +165,10 @@ class EventFactory { BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig) BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig) BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) @@ -205,14 +229,17 @@ class EventFactory { FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig) GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) GitIssueEvent.KIND -> GitIssueEvent(id, pubKey, createdAt, tags, content, sig) GitReplyEvent.KIND -> GitReplyEvent(id, pubKey, createdAt, tags, content, sig) GitPatchEvent.KIND -> GitPatchEvent(id, pubKey, createdAt, tags, content, sig) GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + IndexerRelayListEvent.KIND -> IndexerRelayListEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) @@ -240,12 +267,15 @@ class EventFactory { PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) + ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig) + PublicMessageEvent.KIND -> PublicMessageEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) RelationshipStatusEvent.KIND -> RelationshipStatusEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) + RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) @@ -253,16 +283,16 @@ class EventFactory { TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) + TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) + VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig) + VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> { - factories[kind]?.let { - return it(id, pubKey, createdAt, tags, content, sig) - } - - Event(id, pubKey, createdAt, kind, tags, content, sig) + factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) + ?: Event(id, pubKey, createdAt, kind, tags, content, sig) } - } + } as T } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt index 3c1c0e8fa1..7813f78b92 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.experimental.audio.header.tags import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.ensure class WaveformTag( @@ -38,12 +38,12 @@ class WaveformTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - val wave = runCatching { EventMapper.mapper.readValue>(tag[1]) }.getOrNull() + val wave = runCatching { JsonMapper.mapper.readValue>(tag[1]) }.getOrNull() if (wave.isNullOrEmpty()) return null return WaveformTag(wave) } @JvmStatic - fun assemble(wave: List) = arrayOf(TAG_NAME, EventMapper.mapper.writeValueAsString(wave)) + fun assemble(wave: List) = arrayOf(TAG_NAME, JsonMapper.mapper.writeValueAsString(wave)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt index eada7c6458..3f7e65b260 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -31,7 +33,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ParticipantTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -43,7 +45,7 @@ data class ParticipantTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ParticipantTag(tag[1], tag.getOrNull(2)) + return ParticipantTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }) } @JvmStatic @@ -57,7 +59,7 @@ data class ParticipantTag( @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl? = null, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt index f0f0d5bf89..d41537de40 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt @@ -45,68 +45,62 @@ class DecoupledCipher { pubKey = fromPublicKey.hexToByteArray(), ) - fun encrypt( + suspend fun encrypt( decryptedContent: String, toPublicKey: HexKey, fromKeyList: EncryptionKeyListEvent, toKeyList: EncryptionKeyListEvent, signer: NostrSigner, - onReady: (String) -> Unit, - ) { + ): String? { val toKeys = toKeyList.keys() val sendToKey = if (toKeys.isEmpty()) toKeyList.pubKey else toKeys.random().pubkey val fromKeys = fromKeyList.keys() // uses the main key - if (fromKeys.isEmpty()) { - signer.nip44Encrypt(decryptedContent, sendToKey, onReady) + return if (fromKeys.isEmpty()) { + signer.nip44Encrypt(decryptedContent, sendToKey) } else { val keyToUse = fromKeys.random() - EncryptionKeyCache.getOrLoad( - deriveFromPubKey = signer.pubKey, - nonce = keyToUse.nonce, - load = { onLoaded -> - signer.deriveKey(keyToUse.nonce) { newPrivKey -> - onLoaded(newPrivKey.hexToByteArray()) - } - }, - ) { derivedPrivKey -> - onReady(innerEncrypt(decryptedContent, derivedPrivKey, sendToKey)) - } + EncryptionKeyCache + .getOrLoad( + deriveFromPubKey = signer.pubKey, + nonce = keyToUse.nonce, + load = { signer.deriveKey(keyToUse.nonce).hexToByteArray() }, + )?.let { derivedPrivKey -> + return innerEncrypt(decryptedContent, derivedPrivKey, sendToKey) + } } } - fun decrypt( + suspend fun decrypt( encryptedContent: String, fromPublicKey: HexKey, toPublicKey: HexKey, fromKeyList: EncryptionKeyListEvent, toEncryptedKeyList: EncryptionKeyListEvent, signer: NostrSigner, - onReady: (String) -> Unit, - ) { + ): String? { val fromKeys = fromKeyList.keys() val sentFromKey = if (fromKeys.isEmpty()) fromKeyList.pubKey else fromKeys.random().pubkey val keyToUse = toEncryptedKeyList.keys().firstOrNull { it.pubkey == toPublicKey } // uses the main key - if (signer.pubKey == toPublicKey) { - signer.nip44Decrypt(encryptedContent, sentFromKey, onReady) + return if (signer.pubKey == toPublicKey) { + signer.nip44Decrypt(encryptedContent, sentFromKey) } else if (keyToUse != null) { - EncryptionKeyCache.getOrLoad( - deriveFromPubKey = signer.pubKey, - nonce = keyToUse.nonce, - load = { onLoaded -> - signer.deriveKey(keyToUse.nonce) { newPrivKey -> - onLoaded(newPrivKey.hexToByteArray()) - } - }, - ) { derivedPrivKey -> - innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey)?.let { onReady(it) } - } + EncryptionKeyCache + .getOrLoad( + deriveFromPubKey = signer.pubKey, + nonce = keyToUse.nonce, + load = { signer.deriveKey(keyToUse.nonce).hexToByteArray() }, + )?.let { derivedPrivKey -> + innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey) + } + } else { + null } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt index 5c5308380a..0a812043ff 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt @@ -35,29 +35,29 @@ object EncryptionKeyCache { deriveFromPubKey: HexKey, nonce: HexKey, privKey: ByteArray, - ) = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey) + ): ByteArray? = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey) fun get( deriveFromPubKey: HexKey, nonce: HexKey, - ) = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce)) + ): ByteArray? = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce)) inline fun getOrLoad( deriveFromPubKey: HexKey, nonce: HexKey, - load: (onLoaded: (privKey: ByteArray) -> Unit) -> Unit, - crossinline whenReady: (privKey: ByteArray) -> Unit, - ) { + load: () -> ByteArray?, + ): ByteArray? { val cachedPrivKey = get(deriveFromPubKey, nonce) if (cachedPrivKey != null) { - whenReady(cachedPrivKey) - return + return cachedPrivKey } - load { newPrivKey -> + val newPrivKey = load() + newPrivKey?.let { put(deriveFromPubKey, nonce, newPrivKey) - whenReady(newPrivKey) + newPrivKey } + return newPrivKey } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt deleted file mode 100644 index 6b2e63d48a..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.experimental.edits - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes - -@Immutable -class PrivateOutboxRelayListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var privateTagsCache: Array>? = null - - override fun isContentEncoded() = true - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) - - fun relays(): List? = - tags - .mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - }.plus( - privateTagsCache?.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } ?: emptyList(), - ).ifEmpty { null } - - fun cachedPrivateTags(): Array>? = privateTagsCache - - fun privateTags( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - if (content.isEmpty()) { - onReady(emptyArray()) - return - } - - privateTagsCache?.let { - onReady(it) - return - } - - try { - signer.nip44Decrypt(content, pubKey) { - try { - privateTagsCache = EventMapper.mapper.readValue(it) - privateTagsCache?.let { onReady(it) } - } catch (e: Throwable) { - Log.w("PrivateOutboxRelayListEvent", "Error parsing the JSON: ${e.message}. Json `$it` from event `${toNostrUri()}`") - } - } - } catch (e: Throwable) { - Log.w("PrivateOutboxRelayListEvent", "Error decrypting content: ${e.message}. Event: `${toNostrUri()}`") - } - } - - companion object { - const val KIND = 10013 - val TAGS = arrayOf(AltTag.assemble("Relay list to store private content from this author")) - - fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) - - fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) - - fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val msg = EventMapper.mapper.writeValueAsString(privateTags) - - signer.nip44Encrypt( - msg, - signer.pubKey, - onReady, - ) - } - - fun createTagArray(relays: List): Array> = - relays - .map { - arrayOf("relay", it) - }.toTypedArray() - - fun updateRelayList( - earlierVersion: PrivateOutboxRelayListEvent, - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.privateTagsCache - ?.filter { it[0] != "relay" } - ?.plus( - relays.map { - arrayOf("relay", it) - }, - )?.toTypedArray() ?: emptyArray() - - encryptTags(tags, signer) { - signer.sign(createdAt, KIND, TAGS, it) { - it.privateTagsCache = tags - onReady(it) - } - } - } - - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - val privateTagArray = createTagArray(relays) - encryptTags(privateTagArray, signer) { privateTags -> - signer.sign(createdAt, KIND, TAGS, privateTags) { - it.privateTagsCache = privateTagArray - onReady(it) - } - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt index f0b09f6fc9..39e5a733d7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt @@ -45,15 +45,14 @@ class TextNoteModificationEvent( const val KIND = 1010 const val ALT = "Content Change Event" - fun create( + suspend fun create( content: String, eventId: HexKey, notify: HexKey?, summary: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (TextNoteModificationEvent) -> Unit, - ) { + ): TextNoteModificationEvent { val tags = mutableListOf(arrayOf("e", eventId)) notify?.let { @@ -66,7 +65,7 @@ class TextNoteModificationEvent( tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt index fff181bf43..593a2b5562 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,11 +40,13 @@ class EphemeralChatEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun hasHomeRelay() = tags.any(RelayTag::match) + fun room() = tags.firstNotNullOfOrNull(RoomTag::parse) ?: DEFAULT_ROOM - fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) ?: "" + fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun roomId() = RoomId(room(), relay()) + fun roomId() = relay()?.let { RoomId(room(), it) } companion object { const val KIND = 23333 @@ -53,7 +56,7 @@ class EphemeralChatEvent( fun build( message: String, - relay: String, + relay: NormalizedRelayUrl, room: String = DEFAULT_ROOM, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt index c515febfd4..f4ca23a744 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt @@ -20,13 +20,26 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl data class RoomId( val id: String, - val relayUrl: String, -) { + val relayUrl: NormalizedRelayUrl, +) : Comparable { fun toKey() = "$id@$relayUrl" - fun toDisplayKey() = id + "@" + RelayUrlFormatter.displayUrl(relayUrl) + fun toDisplayKey() = id + "@" + relayUrl.displayUrl() + + override fun compareTo(other: RoomId): Int { + val result = id.compareTo(other.id) + return if (result == 0) { + relayUrl.url.compareTo(other.relayUrl.url) + } else { + result + } + } + + fun toTagArray() = RoomIdTag.assemble(this) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt index 62c04bc099..1b5978f30a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt @@ -23,7 +23,8 @@ package com.vitorpamplona.quartz.experimental.ephemChat.chat import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RelayTag import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun TagArrayBuilder.room(room: String) = addUnique(RoomTag.assemble(room)) -fun TagArrayBuilder.relay(room: String) = addUnique(RelayTag.assemble(room)) +fun TagArrayBuilder.relay(relay: NormalizedRelayUrl) = addUnique(RelayTag.assemble(relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt index 65baca74af..3399d98fc9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat.tags +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure class RelayTag { @@ -28,14 +31,18 @@ class RelayTag { const val TAG_NAME = "relay" @JvmStatic - fun parse(tag: Array): String? { + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return tag[1] + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) } @JvmStatic - fun assemble(url: String) = arrayOf(TAG_NAME, url) + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt index 24fef9b5b9..e97ea19d96 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat.tags +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure @@ -27,6 +28,9 @@ class RoomTag { companion object { const val TAG_NAME = "d" + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } @@ -36,6 +40,6 @@ class RoomTag { } @JvmStatic - fun assemble(url: String) = arrayOf(TAG_NAME, url) + fun assemble(room: String) = arrayOf(TAG_NAME, room) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt index 1d4674b7f5..5d687851d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.LargeCache import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class Room( val roomId: RoomId, ) { - constructor(id: String, relayUrl: String) : this(RoomId(id, relayUrl)) + constructor(id: String, relayUrl: NormalizedRelayUrl) : this(RoomId(id, relayUrl)) val messages = LargeCache() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt index e2b7cb0dd1..2785203e06 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.ephemChat.db import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.LargeCache class EphemeralRoomCache { @@ -28,12 +29,12 @@ class EphemeralRoomCache { fun getRoomIfExists( roomId: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ): Room? = rooms.get(RoomId(roomId, relayUrl)) fun getOrCreateRoom( roomId: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ): Room = rooms.getOrCreate(RoomId(roomId, relayUrl)) { Room(roomId, relayUrl) } fun findRoomsStartingWith(text: String): List { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt index d09b9c647c..9a9de6d9d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt @@ -22,16 +22,24 @@ package com.vitorpamplona.quartz.experimental.ephemChat.list import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.removeParsing import com.vitorpamplona.quartz.utils.TimeUtils -import kotlin.collections.plus +import java.lang.reflect.Modifier.isPrivate @Immutable class EphemeralChatListEvent( @@ -42,30 +50,9 @@ class EphemeralChatListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: Set? = null + fun publicRooms() = tags.rooms() - fun publicAndPrivateRoomIds( - signer: NostrSigner, - onReady: (Set) -> Unit, - ) { - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - return - } - - privateTags(signer) { - val set = filterRooms(it) - publicAndPrivateEventCache = set - onReady(set) - } - } - - fun filterRooms(privateTags: Array>): Set { - val privateRooms = privateTags.mapNotNull(RoomIdTag::parse) - val publicRooms = tags.mapNotNull(RoomIdTag::parse) - - return (privateRooms + publicRooms).toSet() - } + fun publicRoomSet() = tags.roomSet() companion object { const val KIND = 10023 @@ -74,76 +61,120 @@ class EphemeralChatListEvent( fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) - fun createRoom( + suspend fun create( room: RoomId, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (EphemeralChatListEvent) -> Unit, - ) { - val tags = arrayOf(RoomIdTag.Companion.assemble(room)) + ): EphemeralChatListEvent = if (isPrivate) { - PrivateTagsInContent.Companion.encryptNip04( - privateTags = tags, + create( + publicRooms = emptyList(), + privateRooms = listOf(room), signer = signer, - ) { encryptedTags -> - create(encryptedTags, emptyArray(), signer, createdAt, onReady) - } + createdAt = createdAt, + ) } else { - create("", tags, signer, createdAt, onReady) + create( + publicRooms = listOf(room), + privateRooms = emptyList(), + signer = signer, + createdAt = createdAt, + ) } - } - fun removeRoom( + suspend fun add( earlierVersion: EphemeralChatListEvent, room: RoomId, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (EphemeralChatListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.removeAll( - earlierVersion, - RoomIdTag.Companion.assemble(room.id, room.relayUrl), - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) + ): EphemeralChatListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(room.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(room.toTagArray()), + signer = signer, + createdAt = createdAt, + ) } - } - fun addRoom( + suspend fun remove( earlierVersion: EphemeralChatListEvent, room: RoomId, - isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (EphemeralChatListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.add( - earlierVersion, - RoomIdTag.Companion.assemble(room.id, room.relayUrl), - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } + ): EphemeralChatListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.removeParsing(RoomIdTag::parse, room), + tags = earlierVersion.tags.removeParsing(RoomIdTag::parse, room), + signer = signer, + createdAt = createdAt, + ) } - fun create( + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( content: String, - tags: Array>, + tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (EphemeralChatListEvent) -> Unit, - ) { + ): EphemeralChatListEvent { val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { + if (tags.fastAny(AltTag::match)) { tags } else { - tags + AltTag.Companion.assemble(ALT) + tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicRooms: List = emptyList(), + privateRooms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EphemeralChatListEvent { + val template = build(publicRooms, privateRooms, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicRooms: List = emptyList(), + privateRooms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateRooms.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + rooms(publicRooms) + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt index b0a6c3a55a..441cb8d6c7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.list +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -27,3 +28,5 @@ fun TagArrayBuilder.roomId( id: String, relayUrl: String, ) = addUnique(RoomIdTag.assemble(id, relayUrl)) + +fun TagArrayBuilder.rooms(rooms: List) = addAll(rooms.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayExt.kt new file mode 100644 index 0000000000..b45a43d502 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ephemChat.list + +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.rooms() = mapNotNull(RoomIdTag::parse) + +fun TagArray.roomSet() = mapNotNullTo(mutableSetOf(), RoomIdTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt index d8f569a138..382fd64d9a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.experimental.ephemChat.list.tags import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure class RoomIdTag { @@ -30,11 +32,13 @@ class RoomIdTag { @JvmStatic fun parse(tag: Array): RoomId? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } ensure(tag[2].isNotEmpty()) { return null } - return RoomId(tag[1], tag[2]) + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return RoomId(tag[1], relay) } @JvmStatic @@ -44,6 +48,12 @@ class RoomIdTag { ) = arrayOf(TAG_NAME, id, relayUrl) @JvmStatic - fun assemble(id: RoomId) = arrayOf(TAG_NAME, id.id, id.relayUrl) + fun assemble( + id: String, + relay: NormalizedRelayUrl, + ) = assemble(id, relay.url) + + @JvmStatic + fun assemble(id: RoomId) = arrayOf(TAG_NAME, id.id, id.relayUrl.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt index 863a021b28..cc2971896f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.forks +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.MARKER @@ -29,8 +30,8 @@ fun MarkedETag.Companion.parseFork(tag: Array): MarkedETag? { // ["e", id hex, relay hint, marker, pubkey] return MarkedETag( tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], + tag[ORDER_RELAY].ifBlank { null }?.let { RelayUrlNormalizer.normalizeOrNull(it) }, + MARKER.FORK, tag.getOrNull( ORDER_PUBKEY, ), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 83ac71bb4f..1d5725f0fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -83,7 +84,7 @@ class InteractiveStoryReadingStateEvent( fun update( base: InteractiveStoryReadingStateEvent, currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: String?, + currentSceneRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), ): EventTemplate { val rootTag = base.dTag() @@ -109,9 +110,9 @@ class InteractiveStoryReadingStateEvent( fun build( root: InteractiveStoryBaseEvent, - rootRelay: String?, + rootRelay: NormalizedRelayUrl?, currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: String?, + currentSceneRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt index 0a1054f5dd..ab50b9071f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt @@ -23,12 +23,13 @@ package com.vitorpamplona.quartz.experimental.interactiveStories.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class RootSceneTag( @@ -36,13 +37,13 @@ data class RootSceneTag( val pubKeyHex: String, val dTag: String, ) { - var relay: String? = null + var relay: NormalizedRelayUrl? = null constructor( kind: Int, pubKeyHex: HexKey, dTag: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(kind, pubKeyHex, dTag) { this.relay = relayHint } @@ -52,11 +53,11 @@ data class RootSceneTag( 8L + // kind pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toTag() = assembleATagId(kind, pubKeyHex, dTag) - fun toTagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) + fun toTagArray() = assemble(kind, pubKeyHex, dTag, relay) companion object { const val TAG_NAME = "A" @@ -73,21 +74,22 @@ data class RootSceneTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, tag.getOrNull(2)) + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, hint) } @JvmStatic fun assemble( aTagId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKeyHex: String, dTag: String, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt index f9082bd77a..57577ee37e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.experimental.medical import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent.Companion.ALT import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -47,7 +46,7 @@ class FhirResourceEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, fhirPayload, createdAt) { - alt(ALT) + alt(ALT_DESCRIPTION) initializer() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt index a17ed3ef35..a6af5d84e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt @@ -25,9 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.utils.TimeUtils +@Deprecated("Replaced by NIP-68") @Immutable class GalleryListEvent( id: HexKey, @@ -36,108 +37,96 @@ class GalleryListEvent( tags: Array>, content: String, sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 10011 const val ALT = "Profile Gallery" - const val GALLERYTAGNAME = "url" + const val GALLERY_TAG_NAME = "url" - fun addEvent( + suspend fun addEvent( earlierVersion: GalleryListEvent?, eventId: HexKey, url: String, relay: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) = addTag(earlierVersion, GALLERYTAGNAME, eventId, url, relay, signer, createdAt, onReady) + ) = addTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, relay, signer, createdAt) - fun addTag( + suspend fun addTag( earlierVersion: GalleryListEvent?, tagName: String, - eventid: HexKey, + eventId: HexKey, url: String, relay: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { - val tags = arrayOf(tagName, url, eventid) + ): GalleryListEvent { + val tags = arrayOf(tagName, url, eventId) if (relay != null) { tags + relay } - add( + return add( earlierVersion, arrayOf(tags), signer, createdAt, - onReady, ) } - fun add( + suspend fun add( earlierVersion: GalleryListEvent?, listNewTags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { + ): GalleryListEvent = create( content = earlierVersion?.content ?: "", tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()), signer = signer, createdAt = createdAt, - onReady = onReady, ) - } - fun removeEvent( + suspend fun removeEvent( earlierVersion: GalleryListEvent, eventId: HexKey, url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) = removeTag(earlierVersion, GALLERYTAGNAME, eventId, url, signer, createdAt, onReady) + ) = removeTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, signer, createdAt) - fun removeReplaceable( + suspend fun removeReplaceable( earlierVersion: GalleryListEvent, aTag: ATag, url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) = removeTag(earlierVersion, GALLERYTAGNAME, aTag.toTag(), url, signer, createdAt, onReady) + ) = removeTag(earlierVersion, GALLERY_TAG_NAME, aTag.toTag(), url, signer, createdAt) - private fun removeTag( + private suspend fun removeTag( earlierVersion: GalleryListEvent, tagName: String, - eventid: HexKey, + eventId: HexKey, url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { + ): GalleryListEvent = create( content = earlierVersion.content, tags = earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventid) } + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventId) } .toTypedArray(), signer = signer, createdAt = createdAt, - onReady = onReady, ) - } - fun create( + suspend fun create( content: String, tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { + ): GalleryListEvent { val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { tags @@ -145,7 +134,7 @@ class GalleryListEvent( tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt index 7ea3a92bbc..56bcfc9aeb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.experimental.profileGallery import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -68,5 +69,5 @@ fun TagArrayBuilder.service(service: String) = add(Ser fun TagArrayBuilder.fromEvent( event: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = add(ETag.assemble(event, relayHint, null)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt new file mode 100644 index 0000000000..f688b42df5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.publicMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PublicMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + + override fun pubKeyHints() = tags.mapNotNull(ReceiverTag::parseAsHint) + citedNIP19().pubKeyHints() + + override fun linkedPubKeys() = tags.mapNotNull(ReceiverTag::parseKey) + citedNIP19().pubKeys() + + fun isIncluded(pubKey: HexKey) = tags.any(ReceiverTag::match, pubKey) || this.pubKey == pubKey + + fun group() = tags.mapNotNullTo(mutableListOf(ReceiverTag(this.pubKey, null)), ReceiverTag::parse) + + fun groupKeys() = tags.mapNotNullTo(mutableListOf(this.pubKey), ReceiverTag::parseKey) + + fun groupKeySet() = tags.mapNotNullTo(mutableSetOf(this.pubKey), ReceiverTag::parseKey) + + companion object { + const val KIND = 24 + const val ALT_DESCRIPTION = "Public Message" + + fun build( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + } + + fun build( + to: ReceiverTag, + msg: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + toUser(to) + initializer() + } + + fun build( + to: List, + msg: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + toGroup(to) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..9370494dda --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.publicMessages + +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.toGroup(list: List) = addAll(list.map { it.toTagArray() }) + +fun TagArrayBuilder.toUser(user: ReceiverTag) = add(user.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt new file mode 100644 index 0000000000..7378e2086e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.publicMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ReceiverTag( + val pubKey: HexKey, + val relayHint: NormalizedRelayUrl? = null, +) { + fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = pubKey.hexToByteArray().toNpub() + + fun toTagArray() = assemble(pubKey, relayHint) + + fun toTagIdOnly() = assemble(pubKey, null) + + companion object Companion { + const val TAG_NAME = "p" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun match( + tag: Array, + key: HexKey, + ): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Tag): ReceiverTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReceiverTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Tag): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt index ccfbf6db4d..ac22a1e95c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.experimental.relationshipStatus import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetnameTag +import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -45,7 +45,7 @@ class RelationshipStatusEvent( ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) - fun petname() = tags.firstNotNullOfOrNull(PetnameTag::parse) + fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) @@ -53,16 +53,15 @@ class RelationshipStatusEvent( const val KIND = 30382 const val ALT = "Relationship Status" - fun create( + suspend fun create( targetUser: HexKey, - petname: String? = null, + petName: String? = null, summary: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), publicInitializer: TagArrayBuilder.() -> Unit = {}, privateInitializer: TagArrayBuilder.() -> Unit = {}, - onReady: (RelationshipStatusEvent) -> Unit, - ) { + ): RelationshipStatusEvent { val publicTags = tagArray { alt(ALT) @@ -72,14 +71,13 @@ class RelationshipStatusEvent( val privateTags = tagArray { - petname?.let { petname(it) } + petName?.let { petName(it) } summary?.let { summary(it) } privateInitializer() } - PrivateTagsInContent.encryptNip44(privateTags, signer) { content -> - signer.sign(createdAt, KIND, publicTags, content, onReady) - } + val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) + return signer.sign(createdAt, KIND, publicTags, encryptedContent) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt index fb68a96a61..58626823ad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.quartz.experimental.relationshipStatus -import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetnameTag +import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.rank(rank: Int) = add(RankTag.assemble(rank)) -fun TagArrayBuilder.petname(name: String) = add(PetnameTag.assemble(name)) +fun TagArrayBuilder.petName(name: String) = add(PetNameTag.assemble(name)) fun TagArrayBuilder.summary(summary: String) = add(SummaryTag.assemble(summary)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetNameTag.kt similarity index 95% rename from quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetNameTag.kt index 407024e027..32561ccd62 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetNameTag.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.experimental.relationshipStatus.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure -class PetnameTag { +class PetNameTag { companion object { const val TAG_NAME = "petname" @@ -36,6 +36,6 @@ class PetnameTag { } @JvmStatic - fun assemble(petname: String) = arrayOf(TAG_NAME, petname) + fun assemble(petName: String) = arrayOf(TAG_NAME, petName) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt index 638de6571b..3c9d165854 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt @@ -28,9 +28,26 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.MinimumTag import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,7 +58,59 @@ class PollNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + pollOptionsArray().map { "\nOption: " + it.descriptor } + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + fun pollOptionsArray() = tags.mapNotNull(PollOptionTag::parse) fun pollOptions() = pollOptionsArray().associate { it.index to it.descriptor } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt index c8a9cc3be2..81e828a657 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt @@ -41,17 +41,10 @@ fun Event.verifySignature(): Boolean { /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ fun Event.checkSignature() { if (!verifyId()) { - throw Exception( - """ - |Unexpected ID. - | Event: ${toJson()} - | Actual ID: $id - | Generated: ${generateId()} - """.trimIndent(), - ) + throw Exception("ID mismatch: our ID is ${generateId()} for event ${toJson()}") } if (!verifySignature()) { - throw Exception("""Bad signature!""") + throw Exception("Bad signature!") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt index 914b90bb3e..c59f5b0192 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -28,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address interface AddressableEvent : IEvent { fun dTag(): String - fun aTag(relayHint: String? = null): ATag + fun aTag(relayHint: NormalizedRelayUrl? = null): ATag fun address(): Address diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt index b2727bb2e7..cf857f66c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag @@ -38,7 +39,7 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt index c8632b9a5d..8f520077bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -36,7 +37,7 @@ open class BaseReplaceableEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun dTag() = FIXED_D_TAG - override fun aTag(relayHint: String?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt index 8403328d32..09ea417cfe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import android.util.Log import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.annotation.JsonProperty import com.vitorpamplona.quartz.nip01Core.jackson.EventManualSerializer -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -32,9 +32,9 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable open class Event( val id: HexKey, - @JsonProperty("pubkey") val pubKey: HexKey, - @JsonProperty("created_at") val createdAt: Long, - val kind: Int, + val pubKey: HexKey, + val createdAt: Long, + val kind: Kind, val tags: TagArray, val content: String, val sig: HexKey, @@ -45,6 +45,8 @@ open class Event( */ open fun isContentEncoded() = false + open fun extraIndexableTagNames() = emptySet() + open fun countMemory(): Long = 7 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) 12L + // createdAt + kind @@ -57,7 +59,15 @@ open class Event( fun toJson(): String = EventManualSerializer.toJson(id, pubKey, createdAt, kind, tags, content, sig) companion object { - fun fromJson(json: String): Event = EventMapper.fromJson(json) + fun fromJson(json: String): Event = JsonMapper.fromJson(json) + + fun fromJsonOrNull(json: String) = + try { + fromJson(json) + } catch (e: Exception) { + Log.w("Event", "Unable to parse event JSON: $json", e) + null + } fun build( kind: Int, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Kind.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Kind.kt new file mode 100644 index 0000000000..0aea6ba6f0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Kind.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.core + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +typealias Kind = Int + +fun Kind.isEphemeral() = this >= 20_000 && this < 30_000 + +fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000) + +fun Kind.isAddressable() = this >= 30_000 && this < 40_000 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt index 83559fd3b1..f8b8a9926f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.core -import kotlin.collections.indices - typealias TagArray = Array> fun TagArray.builder(initializer: TagArrayBuilder.() -> Unit = {}) = TagArrayBuilder().addAll(this).apply(initializer).build() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt index e16be1da7e..b6fd97e0f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt @@ -48,7 +48,7 @@ class EventAssembler { tags, content, sig, - ) as T + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt index 1d113d1b8b..5fcfd9e8cd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt @@ -24,7 +24,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.sha256.sha256 class EventHasher { @@ -61,7 +61,7 @@ class EventHasher { kind: Int, tags: Array>, content: String, - ): String = EventMapper.toJson(makeJsonObjectForId(pubKey, createdAt, kind, tags, content)) + ): String = JsonMapper.toJson(makeJsonObjectForId(pubKey, createdAt, kind, tags, content)) fun hashIdBytes( pubKey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt index c5bbd6e1a3..56961814c2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.quartz.nip01Core.hints import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -35,10 +37,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class EventHintBundle( val event: T, ) { - var relay: String? = null - var authorHomeRelay: String? = null + var relay: NormalizedRelayUrl? = null + var authorHomeRelay: NormalizedRelayUrl? = null - constructor(event: T, relayHint: String? = null, authorHomeRelay: String? = null) : this(event) { + constructor(event: T, relayHint: NormalizedRelayUrl? = null, authorHomeRelay: NormalizedRelayUrl? = null) : this(event) { this.relay = relayHint this.authorHomeRelay = authorHomeRelay } @@ -46,7 +48,7 @@ data class EventHintBundle( fun countMemory(): Long = 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) event.countMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toNEvent(): String = NEvent.create(event.id, event.pubKey, event.kind, relay) @@ -60,5 +62,5 @@ data class EventHintBundle( fun toETagArray() = ETag.assemble(event.id, relay, event.pubKey) - fun toQTagArray() = ETag(event.id, relay, event.pubKey).toQTagArray() + fun toQTagArray() = QEventTag.assemble(event.id, relay, event.pubKey) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt index 351de15ff3..99fc4a1c13 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.hints import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.bloom.BloomFilterMurMur3 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.LargeCache /** * Instead of having one bloom filter per relay per type, which could create @@ -34,70 +37,72 @@ class HintIndexer { private val eventHints = BloomFilterMurMur3(10_000_000, 5) private val addressHints = BloomFilterMurMur3(2_000_000, 5) private val pubKeyHints = BloomFilterMurMur3(10_000_000, 5) - private val relayDB = mutableSetOf() + private val relayDB = LargeCache() private fun add( id: ByteArray, - relay: String, + relay: NormalizedRelayUrl, bloom: BloomFilterMurMur3, ) { - relayDB.add(relay) - bloom.add(id, relay.hashCode()) + if (!relay.isLocalHost()) { + relayDB.put(relay, relay) + bloom.add(id, relay.hashCode()) + } } - private fun get( + private fun getHintsFor( id: ByteArray, bloom: BloomFilterMurMur3, - ) = relayDB.filter { bloom.mightContain(id, it.hashCode()) } + ) = relayDB.filter { relay, _ -> bloom.mightContain(id, relay.hashCode()) } // -------------------- // Event Host hints // -------------------- fun addEvent( eventId: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(eventId, relay, eventHints) fun addEvent( eventId: HexKey, - relay: String, + relay: NormalizedRelayUrl, ) = addEvent(eventId.hexToByteArray(), relay) - fun getEvent(eventId: ByteArray) = get(eventId, eventHints) + fun hintsForEvent(eventId: ByteArray) = getHintsFor(eventId, eventHints) - fun getEvent(eventId: HexKey) = getEvent(eventId.hexToByteArray()) + fun hintsForEvent(eventId: HexKey) = hintsForEvent(eventId.hexToByteArray()) // -------------------- // PubKeys Outbox hints // -------------------- fun addAddress( addressId: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(addressId, relay, addressHints) fun addAddress( addressId: String, - relay: String, + relay: NormalizedRelayUrl, ) = addAddress(addressId.toByteArray(), relay) - fun getAddress(addressId: ByteArray) = get(addressId, addressHints) + fun hintsForAddress(addressId: ByteArray) = getHintsFor(addressId, addressHints) - fun getAddress(addressId: String) = getAddress(addressId.toByteArray()) + fun hintsForAddress(addressId: String) = hintsForAddress(addressId.toByteArray()) // -------------------- // PubKeys Outbox hints // -------------------- fun addKey( key: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(key, relay, pubKeyHints) fun addKey( key: HexKey, - relay: String, + relay: NormalizedRelayUrl, ) = addKey(key.hexToByteArray(), relay) - fun getKey(key: ByteArray) = get(key, pubKeyHints) + fun hintsForKey(key: ByteArray) = getHintsFor(key, pubKeyHints) - fun getKey(key: HexKey) = getKey(key.hexToByteArray()) + fun hintsForKey(key: HexKey) = hintsForKey(key.hexToByteArray()) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt index e6ddd472d9..704ebd770a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt @@ -20,18 +20,25 @@ */ package com.vitorpamplona.quartz.nip01Core.hints +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint interface EventHintProvider { fun eventHints(): List + + fun linkedEventIds(): List } interface AddressHintProvider { fun addressHints(): List + + fun linkedAddressIds(): List } interface PubKeyHintProvider { fun pubKeyHints(): List + + fun linkedPubKeys(): List } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt index 26c8f35c50..9292790a55 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.hints.types +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + data class AddressHint( val addressId: String, - var relay: String? = null, + val relay: NormalizedRelayUrl, ) : Hint { override fun id() = addressId.toByteArray(Charsets.UTF_8) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt index 88015fe0f8..556cb221b4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt @@ -22,10 +22,18 @@ package com.vitorpamplona.quartz.nip01Core.hints.types import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class EventIdHint( val eventId: HexKey, - var relay: String? = null, + val relay: NormalizedRelayUrl, +) : Hint { + override fun id() = eventId.hexToByteArray() +} + +data class EventIdHintOptional( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, ) : Hint { override fun id() = eventId.hexToByteArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt index a8040bb516..f514f68a49 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.hints.types import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class PubKeyHint( val pubkey: HexKey, - var relay: String? = null, + val relay: NormalizedRelayUrl, ) : Hint { override fun id() = pubkey.hexToByteArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt index e1b49ed4ae..5351028e0a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt @@ -67,7 +67,7 @@ class EventManualSerializer { sig: String, ): String { val obj = assemble(id, pubKey, createdAt, kind, tags, content, sig) - return EventMapper.mapper.writeValueAsString(obj) + return JsonMapper.mapper.writeValueAsString(obj) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt index 286a40a0d5..0e665defd0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt @@ -24,6 +24,7 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.collections.indices class EventSerializer : StdSerializer(Event::class.java) { override fun serialize( @@ -37,7 +38,13 @@ class EventSerializer : StdSerializer(Event::class.java) { gen.writeNumberField("created_at", event.createdAt) gen.writeNumberField("kind", event.kind) gen.writeArrayFieldStart("tags") - event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } + for (i in event.tags.indices) { + gen.writeStartArray() + for (j in event.tags[i].indices) { + gen.writeString(event.tags[i][j]) + } + gen.writeEndArray() + } gen.writeEndArray() gen.writeStringField("content", event.content) gen.writeStringField("sig", event.sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt similarity index 77% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt index 9794d9bb94..ea75d82bf5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt @@ -35,11 +35,17 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.RequestDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.ResponseDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResultJsonDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResultJsonSerializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorSerializer -class EventMapper { +class JsonMapper { companion object { val defaultPrettyPrinter = InliningTagArrayPrettyPrinter() @@ -60,7 +66,13 @@ class EventMapper { .addSerializer(BunkerRequest::class.java, BunkerRequest.BunkerRequestSerializer()) .addDeserializer(BunkerRequest::class.java, BunkerRequest.BunkerRequestDeserializer()) .addSerializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseSerializer()) - .addDeserializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseDeserializer()), + .addDeserializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseDeserializer()) + .addDeserializer(Permission::class.java, PermissionDeserializer()) + .addSerializer(Permission::class.java, PermissionSerializer()) + .addDeserializer(IntentResult::class.java, IntentResultJsonDeserializer()) + .addSerializer(IntentResult::class.java, IntentResultJsonSerializer()) + .addDeserializer(Array>::class.java, TagArrayDeserializer()) + .addSerializer(Array>::class.java, TagArraySerializer()), ) fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayDeserializer.kt new file mode 100644 index 0000000000..fbc44abe32 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayDeserializer.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.deser.std.StdDeserializer + +class TagArrayDeserializer : StdDeserializer>>(Array>::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Array> = TagArrayManualDeserializer.fromJson(jp.codec.readTree(jp)) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayManualDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayManualDeserializer.kt new file mode 100644 index 0000000000..26468af33f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayManualDeserializer.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.fasterxml.jackson.databind.JsonNode + +class TagArrayManualDeserializer { + companion object { + fun fromJson(jsonObject: JsonNode): Array> = + jsonObject.toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArraySerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArraySerializer.kt new file mode 100644 index 0000000000..6152a1c5e9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArraySerializer.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +class TagArraySerializer : StdSerializer>>(Array>::class.java) { + override fun serialize( + tags: Array>, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartArray() + for (i in tags.indices) { + gen.writeStartArray() + for (j in tags[i].indices) { + gen.writeString(tags[i][j]) + } + gen.writeEndArray() + } + gen.writeEndArray() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 80363907ab..7a0ab6f327 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -24,11 +24,11 @@ import android.util.Log import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag @@ -58,12 +58,12 @@ class MetadataEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun contactMetadataJson() = jacksonObjectMapper().readTree(content) as? ObjectNode fun contactMetaData() = try { - EventMapper.mapper.readValue(content, UserMetadata::class.java) + JsonMapper.mapper.readValue(content, UserMetadata::class.java) } catch (e: Exception) { Log.w("MetadataEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}") null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt new file mode 100644 index 0000000000..d741251b7b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -0,0 +1,392 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutboxRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolSubscriptionRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn + +/** + * The NostrClient manages Nostr relay operations, subscriptions, and event delivery. It maintains: + * - A RelayPool for managing connections to a collection of Nostr relays + * - Active subscriptions tracking through PoolSubscriptionRepository + * - An event outbox for managing unsent events and retry logic + * - Automatic relay pool reconciliation based on subscription and event needs + * + * Core responsibilities include: + * - Initializing and managing relay connections using WebSocket builders + * - Coordinating subscription state across multiple relays + * - Handling event and filter resending when relays disconnect and reconnect + * - Aggregating relay status from the pool's state flow + * - Maintaining listeners for propagating relay events and state changes + * + * Features: + * - Reactive updating of relay sets based on subscription and outbox activity + * - Relayer reconnection strategy that processes pending requests + * - Filter comparison logic to detect significant subscription changes and avoid redundant requests + * - Integration with RelayStats for tracking relay performance + * - Thread-safe reconnection methods using coroutine flows + * + * The class combines flows from active subscriptions and the event outbox to ensure relays + * are connected to all necessary endpoints. It also listens to relay state changes to update + * subscriptions and message retries when connections are re-established. + */ +class NostrClient( + private val websocketBuilder: WebsocketBuilder, + private val scope: CoroutineScope, +) : IRelayClientListener { + private val relayPool: RelayPool = RelayPool(this, ::buildRelay) + private val activeRequests: PoolSubscriptionRepository = PoolSubscriptionRepository() + private val activeCounts: PoolSubscriptionRepository = PoolSubscriptionRepository() + private val eventOutbox: PoolEventOutboxRepository = PoolEventOutboxRepository() + + private var listeners = setOf() + + // controls the state of the client in such a way that if it is active + // new filters will be sent to the relays and a potential reconnect can + // be triggered. + private var isActive = false + + /** + * Whatches for any changes in the relay list from subscriptions or outbox + * and updates the relayPool as needed. + */ + @OptIn(FlowPreview::class) + private val allRelays = + combine( + activeRequests.relays, + activeCounts.relays, + eventOutbox.relays, + ) { reqs, counts, outbox -> + reqs + counts + outbox + }.sample(300) + .onEach { + relayPool.updatePool(it) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value, + ) + + fun buildRelay(relay: NormalizedRelayUrl): IRelayClient = + BasicRelayClient( + url = relay, + socketBuilder = websocketBuilder, + listener = relayPool, + stats = RelayStats.get(relay), + scope = scope, + ) { liveRelay -> + if (isActive) { + activeRequests.forEachSub(relay, liveRelay::sendRequest) + activeCounts.forEachSub(relay, liveRelay::sendCount) + eventOutbox.forEachUnsentEvent(relay, liveRelay::send) + } + } + + fun allAvailableRelays() = relayPool.getAll() + + // Reconnects all relays that may have disconnected + fun connect() { + isActive = true + relayPool.connect() + } + + fun disconnect() { + isActive = false + relayPool.disconnect() + } + + @Synchronized + fun reconnect(onlyIfChanged: Boolean = false) { + if (onlyIfChanged) { + relayPool.getAllNeedsToReconnect().forEach { + it.disconnect() + } + relayPool.connect() + } else { + relayPool.disconnect() + relayPool.connect() + } + } + + fun needsToResendRequest( + oldFilters: List, + newFilters: List, + ): Boolean { + if (oldFilters.size != newFilters.size) return true + + oldFilters.forEachIndexed { index, oldFilter -> + val newFilter = newFilters.getOrNull(index) ?: return true + + return needsToResendRequest(oldFilter, newFilter) + } + return false + } + + /** + * Checks if the filter has changed, with a special case for when the since changes due to new + * EOSE times. + */ + fun needsToResendRequest( + oldFilter: Filter, + newFilter: Filter, + ): Boolean { + // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. + // fast check + if (oldFilter.authors?.size != newFilter.authors?.size || + oldFilter.ids?.size != newFilter.ids?.size || + oldFilter.tags?.size != newFilter.tags?.size || + oldFilter.kinds?.size != newFilter.kinds?.size || + oldFilter.limit != newFilter.limit || + oldFilter.search?.length != newFilter.search?.length || + oldFilter.until != newFilter.until + ) { + return true + } + + // deep check + if (oldFilter.ids != newFilter.ids || + oldFilter.authors != newFilter.authors || + oldFilter.tags != newFilter.tags || + oldFilter.kinds != newFilter.kinds || + oldFilter.search != newFilter.search + ) { + return true + } + + if (oldFilter.since != null) { + if (newFilter.since == null) { + // went was checking the future only and now wants everything + return true + } else if (oldFilter.since > newFilter.since) { + // went backwards in time, forces update + return true + } + } + + return false + } + + fun sendRequest( + subId: String = newSubId(), + filters: Map>, + ) { + val oldFilters = activeRequests.getSubscriptionFiltersOrNull(subId) ?: emptyMap() + activeRequests.addOrUpdate(subId, filters) + + if (isActive) { + val allRelays = filters.keys + oldFilters.keys + + allRelays.forEach { relay -> + val oldFilters = oldFilters[relay] + val newFilters = filters[relay] + + if (newFilters.isNullOrEmpty()) { + // some relays are not in this sub anymore. Stop their subscriptions + relayPool.close(relay, subId) + } else if (oldFilters.isNullOrEmpty()) { + // new relays were added. Start a new sub in them + relayPool.sendRequest(relay, subId, newFilters) + } else if (needsToResendRequest(oldFilters, newFilters)) { + // filters were changed enough (not only an update in since) to warn a new update + relayPool.sendRequest(relay, subId, newFilters) + } else { + // makes sure the relay wakes up if it was disconnected by the server + // upon connection, the relay will run the default Sync and update all + // filters, including this one. + relayPool.connectIfDisconnected(relay) + } + } + } + } + + fun sendCount( + subId: String = newSubId(), + filters: Map>, + ) { + val oldFilters = activeCounts.getSubscriptionFiltersOrNull(subId) ?: emptyMap() + activeCounts.addOrUpdate(subId, filters) + + if (isActive) { + val allRelays = filters.keys + oldFilters.keys + + allRelays.forEach { relay -> + val oldFilters = oldFilters[relay] + val newFilters = filters[relay] + + if (newFilters.isNullOrEmpty()) { + // some relays are not in this sub anymore. Stop their subscriptions + relayPool.close(relay, subId) + } else if (oldFilters.isNullOrEmpty()) { + // new relays were added. Start a new sub in them + relayPool.sendCount(relay, subId, newFilters) + } else if (needsToResendRequest(oldFilters, newFilters)) { + // filters were changed enough (not only an update in since) to warn a new update + relayPool.sendCount(relay, subId, newFilters) + } else { + // makes sure the relay wakes up if it was disconnected by the server + // upon connection, the relay will run the default Sync and update all + // filters, including this one. + relayPool.connectIfDisconnected(relay) + } + } + } + } + + fun sendIfExists( + event: Event, + connectedRelay: NormalizedRelayUrl, + ) { + if (isActive) { + relayPool.getRelay(connectedRelay)?.send(event) + } + } + + fun send( + event: Event, + relayList: Set, + ) { + eventOutbox.markAsSending(event, relayList) + if (isActive) { + relayPool.send(event, relayList) + } + } + + fun close(subscriptionId: String) { + activeRequests.remove(subscriptionId) + activeCounts.remove(subscriptionId) + relayPool.close(subscriptionId) + } + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + listeners.forEach { it.onEvent(relay, subId, event, arrivalTime, afterEOSE) } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + listeners.forEach { it.onEOSE(relay, subId, arrivalTime) } + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + listeners.forEach { it.onRelayStateChange(relay, type) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) { + eventOutbox.newTry(event.id, relay.url) + listeners.forEach { it.onBeforeSend(relay, event) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + eventOutbox.newResponse(eventId, relay.url, success, message) + listeners.forEach { it.onSendResponse(relay, eventId, success, message) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) { + listeners.forEach { it.onAuth(relay, challenge) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onNotify( + relay: IRelayClient, + description: String, + ) { + listeners.forEach { it.onNotify(relay, description) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) { + listeners.forEach { it.onSend(relay, msg, success) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + listeners.forEach { it.onError(relay, subId, error) } + } + + fun subscribe(listener: IRelayClientListener) { + listeners = listeners.plus(listener) + } + + fun isSubscribed(listener: IRelayClientListener): Boolean = listeners.contains(listener) + + fun unsubscribe(listener: IRelayClientListener) { + listeners = listeners.minus(listener) + } + + fun getSubscriptionFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) + + fun relayStatusFlow() = relayPool.statusFlow +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt similarity index 80% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt index 156873ac5d..4acf6c0835 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt @@ -18,26 +18,27 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onEvent messages for caching purposes. */ class EventCollector( val client: NostrClient, - val onEvent: (event: Event, relay: Relay) -> Unit, + val onEvent: (event: Event, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt new file mode 100644 index 0000000000..dc80f0c898 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.DelicateCoroutinesApi +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@OptIn(DelicateCoroutinesApi::class) +suspend fun NostrClient.sendAndWaitForResponse( + event: Event, + relayList: Set, + timeoutInSeconds: Long = 15, +): Boolean { + val size = relayList.size + val latch = CountDownLatch(size) + val relayResults = mutableMapOf() + var result = false + + Log.d("sendAndWaitForResponse", "Waiting for $size responses") + + val subscription = + object : IRelayClientListener { + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + if (relay.url in relayList && relayResults[relay.url] == null) { + relayResults[relay.url] = false + latch.countDown() + } + Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} count: ${latch.count} error: $error") + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + if (type == RelayState.DISCONNECTED) { + if (relay.url in relayList && relayResults[relay.url] == null) { + relayResults[relay.url] = false + latch.countDown() + } + } + Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url} count: ${latch.count}") + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + if (eventId == event.id) { + if (relayResults[relay.url] == null) { + latch.countDown() + relayResults[relay.url] = success + } else { + if (success && relayResults[relay.url] == false) { + relayResults[relay.url] = true + } + } + + if (success) { + result = true + } + + Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} count: ${latch.count} message $message success $success") + } + } + } + + subscribe(subscription) + + send(event, relayList) + + latch.await(timeoutInSeconds, TimeUnit.SECONDS) + + unsubscribe(subscription) + + Log.d("sendAndWaitForResponse", "countdown finished") + + return result +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt new file mode 100644 index 0000000000..62869bfbb5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun NostrClient.downloadFirstEvent( + subscriptionId: String = newSubId(), + filters: Map>, + onResponse: (Event) -> Unit, +) { + val listener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (subId == subscriptionId) { + unsubscribe(this) + close(subscriptionId) + + onResponse(event) + } + } + } + + subscribe(listener) + + sendRequest(subscriptionId, filters) + + GlobalScope.launch(Dispatchers.IO) { + delay(30000) + unsubscribe(listener) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt similarity index 71% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt index 93b86c0d89..d8c51d9c29 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt @@ -18,23 +18,29 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch class RelayAuthenticator( val client: NostrClient, - val authenticate: (challenge: String, relay: Relay) -> Unit, + val scope: CoroutineScope, + val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onAuth( - relay: Relay, + relay: IRelayClient, challenge: String, ) { - authenticate(challenge, relay) + scope.launch { + authenticate(challenge, relay) + } } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt similarity index 81% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index d4b7b3cf6b..b23654bd75 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -18,27 +18,28 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onEvent messages for caching purposes. */ class RelayInsertConfirmationCollector( val client: NostrClient, - val onRelayReceived: (eventId: HexKey, relay: Relay) -> Unit, + val onRelayReceived: (eventId: HexKey, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onSendResponse( + relay: IRelayClient, eventId: String, success: Boolean, message: String, - relay: Relay, ) { if (success) { onRelayReceived(eventId, relay) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayLogger.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt similarity index 79% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayLogger.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index bc6721e48c..40bf64eb76 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayLogger.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -18,35 +18,36 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onNotify messages from the relay */ class RelayLogger( val client: NostrClient, - val notify: (message: String, relay: Relay) -> Unit, + val notify: (message: String, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { /** A new message was received */ override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { - Log.d("Relay", "Relay onEVENT ${relay.url} ($subscriptionId - $afterEOSE) ${event.toJson()}") + Log.d("Relay", "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") } override fun onSend( - relay: Relay, + relay: IRelayClient, msg: String, success: Boolean, ) { diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt similarity index 80% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt index 178a6b6763..289d5b95f3 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt @@ -18,27 +18,28 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onNotify messages from the relay */ class RelayNotifier( val client: NostrClient, - val notify: (message: String, relay: Relay) -> Unit, + val notify: (message: String, relay: IRelayClient) -> Unit, ) { companion object { val TAG = RelayNotifier::class.java.simpleName } private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onNotify( - relay: Relay, + relay: IRelayClient, message: String, ) { notify(message, relay) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt new file mode 100644 index 0000000000..364776fe5e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.listeners + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +enum class RelayState { + // Websocket connected + CONNECTED, + + // Websocket disconnecting + DISCONNECTING, + + // Websocket disconnected + DISCONNECTED, +} + +interface IRelayClientListener { + /** + * New Event arrives from the relay. + */ + fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) {} + + /** + * New EOSE command arrives for a subscription + */ + fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) {} + + /** + * New error + */ + fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) {} + + /** + * Relay is requesting authentication with the given challenge. + */ + fun onAuth( + relay: IRelayClient, + challenge: String, + ) {} + + /** + * called after the relay receives the OK from an Auth message + */ + fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) {} + + /** + * RelayState changes + */ + fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) {} + + /** + * NOTIFY command has arrived. + */ + fun onNotify( + relay: IRelayClient, + description: String, + ) {} + + /** + * Relay closed the subscription + */ + fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) {} + + /** + * Triggers this before sending the event. + */ + fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) {} + + /** + * Triggers after the event has been sent. + */ + fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) {} + + /** + * Relay accepted or rejected the event + */ + fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) {} +} + +object EmptyClientListener : IRelayClientListener diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt new file mode 100644 index 0000000000..75c14b13c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.listeners + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +open class RedirectRelayClientListener( + val listener: IRelayClientListener, +) : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) = listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) = listener.onEOSE(relay, subId, arrivalTime) + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) = listener.onError(relay, subId, error) + + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) = listener.onAuth(relay, challenge) + + override fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onAuthed(relay, eventId, success, message) + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) = listener.onRelayStateChange(relay, type) + + override fun onNotify( + relay: IRelayClient, + description: String, + ) = listener.onNotify(relay, description) + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) = listener.onClosed(relay, subId, message) + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) = listener.onBeforeSend(relay, event) + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) = listener.onSend(relay, msg, success) + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onSendResponse(relay, eventId, success, message) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt new file mode 100644 index 0000000000..cef3107a30 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +class PoolEventOutbox( + val event: Event, + var relays: Set, +) { + private val tries = mutableMapOf() + + fun updateRelays(newRelays: Set) { + relays = newRelays + } + + fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false + + fun isDone() = relays.all { isDone(it) } + + fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } + + fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) = if (isSupposedToGo(url)) run(event) else null + + fun newTry(url: NormalizedRelayUrl) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.tries.add(TimeUtils.now()) + } else { + tries.put(url, Tries(mutableListOf(TimeUtils.now()))) + } + } + + fun newResponse( + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.responses.add(Response(success, message)) + } else { + tries.put( + url, + Tries( + mutableListOf(TimeUtils.now() - 1), + mutableListOf(Response(success, message)), + ), + ) + } + } + + // Tries 3 times + class Tries( + val tries: MutableList = mutableListOf(), + val responses: MutableList = mutableListOf(), + ) { + fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + } + + class Response( + val success: Boolean, + val message: String, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt new file mode 100644 index 0000000000..8ca1e03c90 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolEventOutboxRepository { + private var eventOutbox = mapOf() + val relays = MutableStateFlow(setOf()) + + fun updateRelays() { + val myRelays = mutableSetOf() + eventOutbox.values.forEach { + myRelays.addAll(it.relaysLeft()) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun markAsSending( + event: Event, + relays: Set, + ) { + val currentOutbox = eventOutbox[event.id] + if (currentOutbox == null) { + eventOutbox = eventOutbox + Pair(event.id, PoolEventOutbox(event, relays)) + } else { + currentOutbox.updateRelays(relays) + } + updateRelays() + } + + fun newTry( + id: HexKey, + url: NormalizedRelayUrl, + ) { + eventOutbox[id]?.newTry(url) + } + + fun newResponse( + id: HexKey, + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val waiting = eventOutbox[id] + if (waiting != null) { + waiting.newResponse(url, success, message) + clear() + } + } + + fun clear() { + eventOutbox = eventOutbox.filter { !it.value.isDone() } + updateRelays() + } + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) { + eventOutbox.forEach { + it.value.forEachUnsentEvent(url, run) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt new file mode 100644 index 0000000000..c24064a12b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolSubscriptionRepository { + private var subscriptions = LargeCache>>() + val relays = MutableStateFlow(setOf()) + + fun updateRelays() { + val myRelays = mutableSetOf() + subscriptions.forEach { sub, perRelayFilters -> + myRelays.addAll(perRelayFilters.keys) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun addOrUpdate( + subscriptionId: String, + filters: Map>, + ) { + subscriptions.put(subscriptionId, filters) + updateRelays() + } + + fun remove(subscriptionId: String) { + if (subscriptions.containsKey(subscriptionId)) { + subscriptions.remove(subscriptionId) + updateRelays() + } + } + + fun forEachSub( + relay: NormalizedRelayUrl, + run: (String, List) -> Unit, + ) { + subscriptions.forEach { subId, filters -> + val filters = filters[relay] + if (!filters.isNullOrEmpty()) { + run(subId, filters) + } else { + null + } + } + } + + fun getSubscriptionFiltersOrNull(subId: String): Map>? = subscriptions.get(subId) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt new file mode 100644 index 0000000000..ed0fcfa9c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents a filter that should only be sent to a given relay. + */ +class RelayBasedFilter( + val relay: NormalizedRelayUrl, + val filter: Filter, +) + +fun List.groupByRelay(): Map> { + val result = mutableMapOf>() + for (relayBasedFilter in this) { + if (relayBasedFilter.filter.isFilledFilter()) { + result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) + } else { + Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}") + } + } + return result +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt new file mode 100644 index 0000000000..f745e96b5c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -0,0 +1,337 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = { + throw UnsupportedOperationException("Cannot create new relays") +} + +/** + * RelayPool manages a collection of Nostr relays, abstracting individual connections and providing + * unified methods for sending events, managing subscriptions, and tracking relay states. + * + * Key features: + * - Maintains a cache of relays using LargeCache for efficient relay management + * - Propagates event notifications to a shared listener across all relays + * - Provides state tracking through RelayPoolStatus (connected/available relays) + * - Supports relay lifecycle operations like connect/disconnect/reconnect + * - Maintains an immutable createNewRelay function for custom relay creation + * + * Listens to relay events and: + * 1. Forwards callbacks to parent listener + * 2. Updates statusFlow when relay connectivity or events change + * 3. Automatically reconnects relays that need reconnection + * + * Common use cases: + * - Sending events to multiple relays simultaneously (send method) + * - Managing subscriptions across the relay pool (sendRequest/closed methods) + * - Maintaining optimal relay connections (updatePool/addRelay/removeRelay methods) + */ +class RelayPool( + val listener: IRelayClientListener = EmptyClientListener, + val createNewRelay: (url: NormalizedRelayUrl) -> IRelayClient = UnsupportedRelayCreation, +) : IRelayClientListener { + private val relays = LargeCache() + + // Backing property to avoid flow emissions from other classes + private val _statusFlow = MutableStateFlow(RelayPoolStatus()) + val statusFlow: StateFlow = _statusFlow.asStateFlow() + + fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) + + fun getAll() = statusFlow.value.connected + + fun getAllNeedsToReconnect() = relays.filter { url, relay -> relay.needsToReconnect() } + + fun reconnectsRelaysThatNeedTo() { + relays.forEach { url, relay -> + if (relay.needsToReconnect()) { + relay.disconnect() + relay.connect() + } + } + } + + fun connect() = + relays.forEach { url, relay -> + relay.connect() + } + + fun connectIfDisconnected() = + relays.forEach { url, relay -> + relay.connectAndSyncFiltersIfDisconnected() + } + + fun connectIfDisconnected(relay: NormalizedRelayUrl) = relays.get(relay)?.connectAndSyncFiltersIfDisconnected() + + fun disconnect() = + relays.forEach { url, relay -> + relay.disconnect() + } + + fun sendRequest( + relay: NormalizedRelayUrl, + subId: String, + filters: List, + ) { + relays.get(relay)?.sendRequest(subId, filters) + } + + fun sendRequest( + subId: String, + filters: Map>, + ) { + relays.forEach { url, relay -> + val filters = filters[relay.url] + if (!filters.isNullOrEmpty()) { + relay.sendRequest(subId, filters) + } + } + } + + fun sendCount( + relay: NormalizedRelayUrl, + subId: String, + filters: List, + ) { + relays.get(relay)?.sendCount(subId, filters) + } + + fun sendCount( + subId: String, + filters: Map>, + ) { + relays.forEach { url, relay -> + val filters = filters[relay.url] + if (!filters.isNullOrEmpty()) { + relay.sendCount(subId, filters) + } + } + } + + fun close(subscriptionId: String) = + relays.forEach { url, relay -> + relay.close(subscriptionId) + } + + fun close( + relay: NormalizedRelayUrl, + subscriptionId: String, + ) = relays.get(relay)?.close(subscriptionId) + + fun send( + signedEvent: Event, + list: Set, + ) { + list.forEach { + getOrCreateRelay(it).send(signedEvent) + } + } + + // -------------------- + // Pool Maintenance + // -------------------- + fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, createNewRelay) + + fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, createNewRelay) + + /** + * Updates the pool of relays without disconnecting the existing ones. + */ + fun updatePool(newRelays: Set) { + val toRemove = relays.keys() - newRelays + var atLeastOne = false + + newRelays.forEach { + if (createRelayIfAbsent(it)) { + atLeastOne = true + } + } + + toRemove.forEach { + if (removeRelayInner(it)) { + atLeastOne = true + } + } + + if (atLeastOne) { + updateStatus() + } + } + + fun addRelay(relay: NormalizedRelayUrl): IRelayClient { + if (createRelayIfAbsent(relay)) { + updateStatus() + } + return getOrCreateRelay(relay) + } + + fun addAllRelays(relayList: List) { + var atLeastOne = false + relayList.forEach { + if (createRelayIfAbsent(it)) { + atLeastOne = true + } + } + if (atLeastOne) { + updateStatus() + } + } + + private fun removeRelayInner(relay: NormalizedRelayUrl): Boolean { + val relayInPool = relays.remove(relay) + if (relayInPool != null) { + relayInPool.disconnect() + return true + } + return false + } + + fun removeRelay(relay: NormalizedRelayUrl) { + if (removeRelayInner(relay)) { + updateStatus() + } + } + + fun removeAllRelays() { + if (relays.size() > 0) { + disconnect() + relays.clear() + updateStatus() + } + } + + // -------------------- + // Listener Interceptor + // -------------------- + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + } + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + listener.onError(relay, subId, error) + updateStatus() + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + listener.onEOSE(relay, subId, arrivalTime) + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + listener.onRelayStateChange(relay, type) + updateStatus() + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onSendResponse(relay, eventId, success, message) + + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) = listener.onAuth(relay, challenge) + + override fun onNotify( + relay: IRelayClient, + description: String, + ) = listener.onNotify(relay, description) + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) = listener.onClosed(relay, subId, message) + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) = listener.onSend(relay, msg, success) + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) = listener.onBeforeSend(relay, event) + + // --------------- + // STATUS Reports + // --------------- + + fun availableRelays(): Set = relays.keys() + + fun connectedRelays(): Set = + relays.mapNotNullIntoSet { url, relay -> + if (relay.isConnected()) { + url + } else { + null + } + } + + private fun updateStatus() { + val connected = connectedRelays() + val available = availableRelays() + if (_statusFlow.value.connected != connected || _statusFlow.value.available != available) { + _statusFlow.tryEmit(RelayPoolStatus(connected, available)) + } + } + + @Immutable + data class RelayPoolStatus( + val connected: Set = emptySet(), + val available: Set = emptySet(), + val isConnected: Boolean = connected.isNotEmpty(), + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt new file mode 100644 index 0000000000..76b5627225 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.single + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent + +interface IRelayClient { + val url: NormalizedRelayUrl + + fun connect() + + fun needsToReconnect(): Boolean + + fun connectAndRunAfterSync(onConnected: () -> Unit) + + fun connectAndSyncFiltersIfDisconnected() + + fun isConnected(): Boolean + + fun sendRequest( + subId: String, + filters: List, + ) + + fun sendCount( + subId: String, + filters: List, + ) + + fun send(event: Event) + + fun sendAuth(signedEvent: RelayAuthEvent) + + fun sendEvent(event: Event) + + fun close(subscriptionId: String) + + fun disconnect() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt similarity index 86% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt index 29e428b4a0..c2bcf2006d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt @@ -18,12 +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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.single import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import java.util.UUID +import com.vitorpamplona.quartz.utils.RandomInstance + +fun newSubId() = RandomInstance.randomChars(6) class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), + val id: String = newSubId(), val filters: List, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt similarity index 50% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index ba3c98f880..7555915d93 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -18,11 +18,16 @@ * 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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_MSECS +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage @@ -37,27 +42,54 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException -class SimpleClientRelay( - val url: String, +/** + * A base implementation for a relay client that establishes and manages a WebSocket connection to a Nostr relay. + * This class provides fundamental connection handling, message parsing, reconnection logic, and event dispatching. + * + * @property url The relay's normalized URL. + * @property socketBuilder Provides the WebSocket instance for connection. + * @property listener Interface to notify the application of relay events and errors. + * @property stats Tracks operational statistics of the relay connection. + * @property defaultOnConnect Callback executed after a successful connection, allowing subclasses to add initialization logic. + * + * Reconnection Strategy: + * - Uses exponential backoff to retry connections, starting with [DELAY_TO_RECONNECT_IN_MSECS] (500ms). + * - Doubles the delay between reconnection attempts in case of failure. + * + * Message Handling: + * - Processes relay messages (e.g., `EVENT`, `EOSE`, `OK`, `AUTH`) and delegates to appropriate callbacks. + * - Dispatches received events, notices, and subscription closures via the [listener]. + */ +open class BasicRelayClient( + override val url: NormalizedRelayUrl, val socketBuilder: WebsocketBuilder, - val subs: SubscriptionCollection, - val listener: Listener, + val listener: IRelayClientListener, val stats: RelayStat = RelayStat(), -) { + val scope: CoroutineScope, + val defaultOnConnect: (BasicRelayClient) -> Unit = { }, +) : IRelayClient { companion object { // waits 3 minutes to reconnect once things fail const val DELAY_TO_RECONNECT_IN_MSECS = 500L + const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\"" } + private val logTag = "Relay ${url.displayUrl()}" + private var socket: WebSocket? = null private var isReady: Boolean = false private var usingCompression: Boolean = false @@ -70,45 +102,29 @@ class SimpleClientRelay( private val authResponseWatcher = mutableMapOf() private val authChallengesSent = mutableSetOf() - /** - * Auth procedures require us to keep track of the outgoing events - * to make sure the relay waits for the auth to finish and send them. - */ - private val outboxCache = mutableMapOf() - private var connectingMutex = AtomicBoolean() private val parser = ToClientParser() fun isConnectionStarted(): Boolean = socket != null - fun isConnected(): Boolean = socket != null && isReady + override fun isConnected(): Boolean = socket != null && isReady - fun connect() = connectAndRunOverride(::sendEverything) + override fun needsToReconnect() = socket?.needsReconnect() ?: true - fun sendEverything() { - renewSubscriptions() - sendOutbox() - } - - fun sendOutbox() { - synchronized(outboxCache) { - outboxCache.values.forEach { - send(it) - } - } - } - - fun connectAndRunAfterSync(onConnected: () -> Unit) { + override fun connect() = connectAndRunOverride { - sendEverything() + defaultOnConnect(this) + } + + override fun connectAndRunAfterSync(onConnected: () -> Unit) { + connectAndRunOverride { + defaultOnConnect(this) onConnected() } } fun connectAndRunOverride(onConnected: () -> Unit) { - Log.d("Relay", "Relay.connect $url isAlreadyConnecting: ${connectingMutex.get()}") - // If there is a connection, don't wait. if (connectingMutex.getAndSet(true)) { return @@ -120,49 +136,50 @@ class SimpleClientRelay( return } + Log.d(logTag, "Connecting...") + lastConnectTentative = TimeUtils.now() - socket = socketBuilder.build(url, RelayListener(onConnected)) + socket = socketBuilder.build(url, MyWebsocketListener(onConnected)) socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e + Log.w(logTag, "Crash before connecting", e) stats.newError(e.message ?: "Error trying to connect: ${e.javaClass.simpleName}") markConnectionAsClosed() - e.printStackTrace() } finally { connectingMutex.set(false) } } - inner class RelayListener( + inner class MyWebsocketListener( val onConnected: () -> Unit, ) : WebSocketListener { override fun onOpen( pingMillis: Long, compression: Boolean, ) { - Log.d("Relay", "Connect onOpen $url $socket") + Log.d(logTag, "OnOpen (ping: ${pingMillis}ms${if (compression) ", using compression" else ""})") markConnectionAsReady(pingMillis, compression) - // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") - onConnected() + scope.launch(Dispatchers.Default) { + onConnected() + } - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.CONNECTED) + listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED) } override fun onMessage(text: String) { - stats.addBytesReceived(text.bytesUsedInMemory()) - - try { - processNewRelayMessage(text) - } catch (e: Throwable) { - if (e is CancellationException) throw e - stats.newError("Error processing: $text") - Log.e("Relay", "Error processing: $text") - listener.onError(this@SimpleClientRelay, "", Error("Error processing $text")) + if (text.startsWith(EVENT_MESSAGE_PREFIX)) { + // defers the parsing of ["EVENTS" to avoid blocking the HTTP thread + scope.launch(Dispatchers.Default) { + consumeIncomingCommand(text, onConnected) + } + } else { + consumeIncomingCommand(text, onConnected) } } @@ -170,27 +187,27 @@ class SimpleClientRelay( code: Int, reason: String, ) { - Log.w("Relay", "Relay onClosing $url: $reason") + Log.w(logTag, "OnClosing $code $reason") - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.DISCONNECTING) + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTING) } override fun onClosed( code: Int, reason: String, ) { + Log.w(logTag, "OnClosed $reason") + markConnectionAsClosed() - - Log.w("Relay", "Relay onClosed $url: $reason") - - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.DISCONNECTED) + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) } override fun onFailure( t: Throwable, + code: Int?, response: String?, ) { - socket?.cancel() // 1000, "Normal close" + socket?.disconnect() // 1000, "Normal close" // checks if this is an actual failure. Closing the socket generates an onFailure as well. if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { @@ -200,16 +217,41 @@ class SimpleClientRelay( // Failures disconnect the relay. markConnectionAsClosed() - Log.w("Relay", "Relay onFailure $url, $response $response ${t.message} $socket") - t.printStackTrace() + Log.w(logTag, "OnFailure $code $response ${t.message} $socket") listener.onError( - this@SimpleClientRelay, + this@BasicRelayClient, "", - Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), + Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t), ) } } + fun consumeIncomingCommand( + text: String, + onConnected: () -> Unit, + ) { + // Log.d(logTag, "Receiving: $text") + stats.addBytesReceived(text.bytesUsedInMemory()) + + try { + when (val msg = parser.parse(text)) { + is EventMessage -> processEvent(msg) + is EoseMessage -> processEose(msg) + is NoticeMessage -> processNotice(msg) + is OkMessage -> processOk(msg, onConnected) + is AuthMessage -> processAuth(msg) + is NotifyMessage -> processNotify(msg) + is ClosedMessage -> processClosed(msg) + else -> processUnknownMessage(text) + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + stats.newError("Error processing: $text") + Log.e(logTag, "Error processing: $text") + listener.onError(this@BasicRelayClient, "", Error("Error processing $text")) + } + } + fun markConnectionAsReady( pingInMs: Long, usingCompression: Boolean, @@ -231,73 +273,80 @@ class SimpleClientRelay( this.resetEOSEStatuses() } - fun processNewRelayMessage(newMessage: String) { - when (val msg = parser.parse(newMessage)) { - is EventMessage -> { - listener.onEvent(this, msg.subId, msg.event, TimeUtils.now(), afterEOSEPerSubscription[msg.subId] == true) - } - is EoseMessage -> { - // Log.w("Relay", "Relay onEOSE $url $newMessage") - afterEOSEPerSubscription[msg.subId] = true - listener.onEOSE(this, msg.subId, TimeUtils.now()) - } - is NoticeMessage -> { - // Log.w("Relay", "Relay onNotice $url, $newMessage") - stats.newNotice(msg.message) - listener.onError(this@SimpleClientRelay, msg.message, Error("Relay sent notice: $msg.message")) - } - is OkMessage -> { - Log.w("Relay", "Relay on OK $url, $newMessage") - - // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authResponseWatcher.containsKey(msg.eventId)) { - val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] - authResponseWatcher.put(msg.eventId, msg.success) - if (wasAlreadyAuthenticated != true && msg.success) { - sendEverything() - } - } - - // remove from cache for any error that is not an auth required error. - // for auth required, we will do the auth and try to send again. - if (outboxCache.contains(msg.eventId) && !msg.message.startsWith("auth-required")) { - synchronized(outboxCache) { - outboxCache.remove(msg.eventId) - } - } - - if (!msg.success) { - stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") - } - - listener.onSendResponse(this@SimpleClientRelay, msg.eventId, msg.success, msg.message) - } - is AuthMessage -> { - // Log.d("Relay", "Relay onAuth $url, $newMessage") - listener.onAuth(this@SimpleClientRelay, msg.challenge) - } - is NotifyMessage -> { - // Log.w("Relay", "Relay onNotify $url, $newMessage") - listener.onNotify(this@SimpleClientRelay, msg.message) - } - is ClosedMessage -> { - afterEOSEPerSubscription[msg.subscriptionId] = false - // Log.w("Relay", "Relay Closed Subscription $url, $newMessage") - listener.onClosed(this@SimpleClientRelay, msg.subscriptionId, msg.message) - } - else -> { - stats.newError("Unsupported message: $newMessage") - Log.w("Relay", "Unsupported message: $newMessage") - listener.onError(this, "", Error("Unsupported message: $newMessage")) - } - } + private fun processEvent(msg: EventMessage) { + // Log.w(logTag, "Event ${msg.subId} ${msg.event.toJson()}") + listener.onEvent( + relay = this, + subId = msg.subId, + event = msg.event, + arrivalTime = TimeUtils.now(), + afterEOSE = afterEOSEPerSubscription[msg.subId] == true, + ) } - fun disconnect() { - Log.d("Relay", "Relay.disconnect $url") + private fun processEose(msg: EoseMessage) { + // Log.w(logTag, "EOSE ${msg.subId}") + afterEOSEPerSubscription[msg.subId] = true + listener.onEOSE(this, msg.subId, TimeUtils.now()) + } + + private fun processNotice(msg: NoticeMessage) { + Log.w(logTag, "Notice ${msg.message}") + stats.newNotice(msg.message) + listener.onError(this@BasicRelayClient, msg.message, Error("Relay sent notice: $msg.message")) + } + + private fun processOk( + msg: OkMessage, + onConnected: () -> Unit, + ) { + Log.w(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") + + // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. + if (authResponseWatcher.containsKey(msg.eventId)) { + val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] + authResponseWatcher.put(msg.eventId, msg.success) + if (wasAlreadyAuthenticated != true && msg.success) { + onConnected() + listener.onAuthed(this@BasicRelayClient, msg.eventId, msg.success, msg.message) + } + } + + if (!msg.success) { + stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") + } + + listener.onSendResponse(this@BasicRelayClient, msg.eventId, msg.success, msg.message) + } + + private fun processAuth(msg: AuthMessage) { + // Log.d(logTag, "Auth $newMessage") + listener.onAuth(this@BasicRelayClient, msg.challenge) + } + + private fun processNotify(msg: NotifyMessage) { + // Log.w(logTag, "Notify $newMessage") + listener.onNotify(this@BasicRelayClient, msg.message) + } + + private fun processClosed(msg: ClosedMessage) { + Log.w(logTag, "Relay Closed Subscription ${msg.subscriptionId} ${msg.message}") + stats.newNotice("Subscription closed: ${msg.subscriptionId} ${msg.message}") + afterEOSEPerSubscription[msg.subscriptionId] = false + listener.onClosed(this@BasicRelayClient, msg.subscriptionId, msg.message) + } + + private fun processUnknownMessage(newMessage: String) { + stats.newError("Unsupported message: $newMessage") + Log.w(logTag, "Unsupported message: $newMessage") + listener.onError(this, "", Error("Unsupported message: $newMessage")) + } + + override fun disconnect() { + Log.d(logTag, "Disconnecting...") lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. delayToConnect = DELAY_TO_RECONNECT_IN_MSECS - socket?.cancel() + socket?.disconnect() socket = null isReady = false usingCompression = false @@ -311,15 +360,15 @@ class SimpleClientRelay( authChallengesSent.clear() } - fun sendRequest( - requestId: String, + override fun sendRequest( + subId: String, filters: List, ) { if (isConnectionStarted()) { if (isReady) { if (filters.isNotEmpty()) { - afterEOSEPerSubscription[requestId] = false - writeToSocket(ReqCmd.toJson(requestId, filters)) + afterEOSEPerSubscription[subId] = false + writeToSocket(ReqCmd.Companion.toJson(subId, filters)) } } } else { @@ -332,15 +381,15 @@ class SimpleClientRelay( } } - fun sendCount( - requestId: String, + override fun sendCount( + subId: String, filters: List, ) { if (isConnectionStarted()) { if (isReady) { if (filters.isNotEmpty()) { - afterEOSEPerSubscription[requestId] = false - writeToSocket(CountCmd.toJson(requestId, filters)) + afterEOSEPerSubscription[subId] = false + writeToSocket(CountCmd.Companion.toJson(subId, filters)) } } } else { @@ -353,7 +402,7 @@ class SimpleClientRelay( } } - fun connectAndSendFiltersIfDisconnected() { + override fun connectAndSyncFiltersIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. if (TimeUtils.now() > lastConnectTentative + delayToConnect) { @@ -363,42 +412,31 @@ class SimpleClientRelay( } } - fun renewSubscriptions() { - // Force update all filters after AUTH. - subs.allSubscriptions().forEach { - sendRequest(requestId = it.id, it.filters) - } - } + override fun send(event: Event) { + listener.onBeforeSend(this@BasicRelayClient, event) - fun send(signedEvent: Event) { - listener.onBeforeSend(this@SimpleClientRelay, signedEvent) - - if (signedEvent is RelayAuthEvent) { - sendAuth(signedEvent) + if (event is RelayAuthEvent) { + sendAuth(event) } else { - sendEvent(signedEvent) + sendEvent(event) } } - fun sendAuth(signedEvent: RelayAuthEvent) { + override fun sendAuth(signedEvent: RelayAuthEvent) { val challenge = signedEvent.challenge() // only send replies to new challenges to avoid infinite loop: if (challenge != null && challenge !in authChallengesSent) { authResponseWatcher[signedEvent.id] = false authChallengesSent.add(challenge) - writeToSocket(AuthCmd.toJson(signedEvent)) + writeToSocket(AuthCmd.Companion.toJson(signedEvent)) } } - fun sendEvent(signedEvent: Event) { - synchronized(outboxCache) { - outboxCache.put(signedEvent.id, signedEvent) - } - + override fun sendEvent(event: Event) { if (isConnectionStarted()) { if (isReady) { - writeToSocket(EventCmd.toJson(signedEvent)) + writeToSocket(EventCmd.Companion.toJson(event)) } } else { // automatically sends all filters after connection is successful. @@ -409,111 +447,24 @@ class SimpleClientRelay( private fun writeToSocket(str: String) { if (socket == null) { listener.onError( - this@SimpleClientRelay, + this@BasicRelayClient, "", Error("Failed to send $str. Relay is not connected."), ) } socket?.let { + // Log.d(logTag, "Sending: $str") val result = it.send(str) - listener.onSend(this@SimpleClientRelay, str, result) + listener.onSend(this@BasicRelayClient, str, result) stats.addBytesSent(str.bytesUsedInMemory()) } } - fun close(subscriptionId: String) { - writeToSocket(CloseCmd.toJson(subscriptionId)) - afterEOSEPerSubscription[subscriptionId] = false - } - - interface Listener { - /** - * New Event arrives from the relay. - */ - fun onEvent( - relay: SimpleClientRelay, - subscriptionId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, - ) - - /** - * New EOSE command arrives for a subscription - */ - fun onEOSE( - relay: SimpleClientRelay, - subscriptionId: String, - arrivalTime: Long, - ) - - /** - * New error - */ - fun onError( - relay: SimpleClientRelay, - subscriptionId: String, - error: Error, - ) - - /** - * Relay is requesting authentication with the given challenge. - */ - fun onAuth( - relay: SimpleClientRelay, - challenge: String, - ) - - /** - * RelayState changes - */ - fun onRelayStateChange( - relay: SimpleClientRelay, - type: RelayState, - ) - - /** - * NOTIFY command has arrived. - */ - fun onNotify( - relay: SimpleClientRelay, - description: String, - ) - - /** - * Relay closed the subscription - */ - fun onClosed( - relay: SimpleClientRelay, - subscriptionId: String, - message: String, - ) - - /** - * Triggers this before sending the event. - */ - fun onBeforeSend( - relay: SimpleClientRelay, - event: Event, - ) - - /** - * Triggers after the event has been sent. - */ - fun onSend( - relay: SimpleClientRelay, - msg: String, - success: Boolean, - ) - - /** - * Relay accepted or rejected the event - */ - fun onSendResponse( - relay: SimpleClientRelay, - eventId: String, - success: Boolean, - message: String, - ) + override fun close(subscriptionId: String) { + // avoids sending closes for subscriptions that were never sent to this relay. + if (afterEOSEPerSubscription.containsKey(subscriptionId)) { + writeToSocket(CloseCmd.toJson(subscriptionId)) + afterEOSEPerSubscription[subscriptionId] = false + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt new file mode 100644 index 0000000000..7d6d581099 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.single.simple + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.LargeCache + +class OutboxCache( + listener: IRelayClientListener, +) : RedirectRelayClientListener(listener) { + /** + * Auth procedures require us to keep track of the outgoing events + * to make sure the relay waits for the auth to finish and send them. + */ + private val outboxCache = LargeCache() + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + if (type == RelayState.CONNECTED) { + outboxCache.forEach { id, event -> + relay.sendEvent(event) + } + } + + super.onRelayStateChange(relay, type) + } + + override fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + super.onAuthed(relay, eventId, success, message) + outboxCache.forEach { id, event -> + relay.sendEvent(event) + } + } + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) { + if (event !is RelayAuthEvent) { + outboxCache.put(event.id, event) + } + super.onBeforeSend(relay, event) + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + // remove from cache for any error that is not an auth required error. + // for auth required, we will do the auth and try to send again. + if (outboxCache.containsKey(eventId) && !message.startsWith("auth-required")) { + outboxCache.remove(eventId) + } + + super.onSendResponse(relay, eventId, success, message) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt new file mode 100644 index 0000000000..0619ffb41e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.single.simple + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.CoroutineScope + +/** + * This relay client saves any event that will be sent in an outbox + * waits for auth and sends it again to make sure it is delivered. + */ +class SimpleRelayClient( + url: NormalizedRelayUrl, + socketBuilder: WebsocketBuilder, + listener: IRelayClientListener, + stats: RelayStat = RelayStat(), + scopeToParseEvents: CoroutineScope, + defaultOnConnect: (BasicRelayClient) -> Unit = { }, +) : IRelayClient by BasicRelayClient( + url, + socketBuilder, + OutboxCache(listener), + stats, + scopeToParseEvents, + defaultOnConnect, + ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index aa64801c3e..fd266e0ffc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt similarity index 74% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index 033f6673ed..b12e95e2c7 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -18,52 +18,56 @@ * 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.ammolite.relays +package com.vitorpamplona.quartz.nip01Core.relay.client.stats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl object RelayStats { - private val innerCache = mutableMapOf() + private val innerCache = + object : LruCache(1000) { + override fun create(key: NormalizedRelayUrl?) = RelayStat() + } - fun get(url: String): RelayStat = innerCache.getOrPut(url) { RelayStat() } + fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) fun addBytesReceived( - url: String, + url: NormalizedRelayUrl, bytesUsedInMemory: Long, ) { get(url).addBytesReceived(bytesUsedInMemory) } fun addBytesSent( - url: String, + url: NormalizedRelayUrl, bytesUsedInMemory: Long, ) { get(url).addBytesSent(bytesUsedInMemory) } fun newError( - url: String, + url: NormalizedRelayUrl, error: String?, ) { get(url).newError(error) } fun newNotice( - url: String, + url: NormalizedRelayUrl, notice: String?, ) { get(url).newNotice(notice) } fun setPing( - url: String, + url: NormalizedRelayUrl, pingInMs: Long, ) { get(url).pingInMs = pingInMs } fun newSpam( - url: String, + url: NormalizedRelayUrl, explanation: String, ) { get(url).newSpam(explanation) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt new file mode 100644 index 0000000000..8ed3767696 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +data class Subscription( + val id: String = newSubId(), + val onEose: ((time: Long, relayUrl: NormalizedRelayUrl) -> Unit)? = null, +) { + private var filters: Map>? = null // Inactive when null + + fun reset() { + filters = null + } + + fun updateFilters(newFilters: Map>?) { + filters = newFilters + } + + fun filters() = filters + + fun callEose( + time: Long, + relay: NormalizedRelayUrl, + ) { + onEose?.let { it(time, relay) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt new file mode 100644 index 0000000000..dac7e7f2d0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache + +/** + * Manages Nostr subscriptions using a [NostrClient], allowing subscriptions to be created, modified, + * and synchronized with relay filters. Subscriptions are stored in a cache and processed through + * [updateRelays] to update relay filters dynamically. Also tracks event statistics and EOSE (End of + * Stored Events) events, and provides utility methods to interact with subscriptions like dismissal. + * + * Key responsibilities: + * 1. Maintain a cache of active [Subscription] instances. + * 2. Handle client events (onEvent, onEOSE) using [clientListener]. + * 3. Synchronize relay filters via [updateRelays] when subscriptions change. + * 4. Provide methods to create, dismiss, and inspect subscriptions. + * + * Usage: + * - Use [requestNewSubscription] to create subscriptions. + * - Modify filters on [Subscription] at will and call [updateRelays] to apply changes. + * - Update filters based on EOSE callbacks on each subscription + * - Dismiss subscriptions with [dismissSubscription]. + */ +class SubscriptionController( + val client: NostrClient, +) { + private val subscriptions = LargeCache() + private val stats = SubscriptionStats() + + private val clientListener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (subscriptions.containsKey(subId)) { + stats.add(subId, event.kind) + if (afterEOSE) { + subscriptions.get(subId)?.callEose(arrivalTime, relay.url) + } + } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + if (subscriptions.containsKey(subId)) { + subscriptions.get(subId)?.callEose(arrivalTime, relay.url) + } + } + } + + init { + client.subscribe(clientListener) + } + + fun destroy() { + client.unsubscribe(clientListener) + } + + fun printStats(tag: String) = stats.printCounter(tag) + + fun getSub(subId: String) = subscriptions.get(subId) + + fun requestNewSubscription( + subId: String, + onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null, + ): Subscription = Subscription(subId, onEose = onEOSE).also { subscriptions.put(it.id, it) } + + fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } + + fun dismissSubscription(subscription: Subscription) { + client.close(subscription.id) + subscription.reset() + subscriptions.remove(subscription.id) + } + + fun updateRelays() { + val currentFilters = + subscriptions.associateWith { id, sub -> + client.getSubscriptionFiltersOrNull(id) + } + + subscriptions.forEach { id, sub -> + updateRelaysIfNeeded(id, sub.filters(), currentFilters[id]) + } + } + + fun updateRelaysIfNeeded( + subId: String, + updatedFilters: Map>?, + currentFilters: Map>?, + ) { + if (currentFilters != null) { + if (updatedFilters == null) { + // was active and is not active anymore, just close. + client.close(subId) + } else { + client.sendRequest(subId, updatedFilters) + } + } else { + if (updatedFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the entire filter. + client.sendRequest(subId, updatedFilters) + } + } + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt similarity index 75% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt index 554a30cf1e..87d01b85eb 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt @@ -18,20 +18,22 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions import android.util.Log +import com.vitorpamplona.quartz.utils.LargeCache class SubscriptionStats { data class Counter( val subscriptionId: String, val eventKind: Int, - var counter: Int, - ) + ) { + var counter: Int = 0 + } - private var eventCounter = mapOf() + private var eventCounter = LargeCache() - fun eventCounterIndex( + private fun eventCounterIndex( str1: String, str2: Int, ): Int = 31 * str1.hashCode() + str2.hashCode() @@ -41,19 +43,15 @@ class SubscriptionStats { eventKind: Int, ) { val key = eventCounterIndex(subscriptionId, eventKind) - val keyValue = eventCounter[key] - if (keyValue != null) { - keyValue.counter++ - } else { - eventCounter = eventCounter + Pair(key, Counter(subscriptionId, eventKind, 1)) - } + val stats = eventCounter.getOrCreate(key) { Counter(subscriptionId, eventKind) } + stats.counter++ } fun printCounter(tag: String) { - eventCounter.forEach { + eventCounter.forEach { _, stats -> Log.d( tag, - "Received Events ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}", + "Received Events ${stats.subscriptionId} ${stats.eventKind}: ${stats.counter}", ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt index c161f87d9f..ee71c2381d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class EventMessage( val subId: String, @@ -35,7 +35,7 @@ class EventMessage( fun parse(msgArray: JsonNode): EventMessage = EventMessage( msgArray.get(1).asText(), - EventMapper.fromJson(msgArray.get(2)), + JsonMapper.fromJson(msgArray.get(2)), ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt index 7258602fe9..f324c42c0c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class ToClientParser { fun parse(newMessage: String): Message? { - val msgArray = EventMapper.mapper.readTree(newMessage) + val msgArray = JsonMapper.mapper.readTree(newMessage) val type = msgArray.get(0).asText() return when (type) { EventMessage.LABEL -> EventMessage.parse(msgArray) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt index 612a9c5ca0..95c11e8889 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent class AuthCmd( @@ -36,7 +36,7 @@ class AuthCmd( @JvmStatic fun parse(msgArray: JsonNode): AuthCmd = AuthCmd( - EventMapper.fromJson(msgArray.get(1)) as RelayAuthEvent, + JsonMapper.fromJson(msgArray.get(1)) as RelayAuthEvent, ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt index 4e631893c2..7c9c7cc4e6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterDeserializer import com.vitorpamplona.quartz.utils.joinToStringLimited @@ -54,7 +54,7 @@ class CountCmd( val filters = mutableListOf() for (i in 2 until msgArray.size()) { - val json = EventMapper.mapper.readTree(msgArray.get(i).asText()) + val json = JsonMapper.mapper.readTree(msgArray.get(i).asText()) if (json is ObjectNode) { filters.add(FilterDeserializer.fromJson(json)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt index cc7dae3b1d..ea8349a246 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class EventCmd( val event: Event, @@ -36,7 +36,7 @@ class EventCmd( @JvmStatic fun parse(msgArray: JsonNode): EventCmd = EventCmd( - EventMapper.fromJson(msgArray.get(1)), + JsonMapper.fromJson(msgArray.get(1)), ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt index 08390c0a86..01439d24b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterDeserializer import com.vitorpamplona.quartz.utils.joinToStringLimited @@ -54,7 +54,7 @@ class ReqCmd( val filters = mutableListOf() for (i in 2 until msgArray.size()) { - val json = EventMapper.mapper.readTree(msgArray.get(i).asText()) + val json = JsonMapper.mapper.readTree(msgArray.get(i).asText()) if (json is ObjectNode) { filters.add(FilterDeserializer.fromJson(json)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt index 97ebf536d6..1f0e558793 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class ToRelayParser { fun parse(newMessage: String): Command? { - val msgArray = EventMapper.mapper.readTree(newMessage) + val msgArray = JsonMapper.mapper.readTree(newMessage) val type = msgArray.get(0).asText() return when (type) { AuthCmd.LABEL -> AuthCmd.parse(msgArray) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index 9b7cf1a521..d62ddfacef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -20,8 +20,27 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.filters +import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +/** + * A filter for Nostr events used in relay subscriptions. Supports various criteria + * to match events based on IDs, authors, kinds, tags, time ranges, search terms, and limits. + * + * Parameters: + * - ids: Optional list of event IDs to match (must be 64 characters). + * - authors: Optional list of author public keys (must be 64 characters). + * - kinds: Optional list of event kinds to include. + * - tags: Optional map of tag names to values arrays (common tags like 'p', 'e', 'a' are validated). + * - since: Optional timestamp for filtering events with publication time ≥ this value. + * - until: Optional timestamp for filtering events with publication time ≤ this value. + * - limit: Optional maximum number of events to request. + * - search: Optional string to search within event content. + * + * This class performs validation on construction to ensure all string-based identifiers + * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs. + */ class Filter( val ids: List? = null, val authors: List? = null, @@ -46,4 +65,36 @@ class Filter( limit: Int? = this.limit, search: String? = this.search, ) = Filter(ids, authors, kinds, tags, since, until, limit, search) + + /** + * Returns true if this filter contains any non-null and non-empty criteria. + */ + fun isFilledFilter() = + (ids != null && ids.isNotEmpty()) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) || + (since != null) || + (until != null) || + (limit != null) || + (search != null && search.isNotEmpty()) + + init { + ids?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid id length $it on ${toJson()}") + } + authors?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}") + } + // tests common tags. + tags?.get("p")?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + } + tags?.get("e")?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + } + tags?.get("a")?.forEach { + if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index dc1a90ffc6..8756646402 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper object FilterSerializer { fun toJsonObject( @@ -80,7 +80,7 @@ object FilterSerializer { limit: Int? = null, search: String? = null, ): String = - EventMapper.mapper.writeValueAsString( + JsonMapper.mapper.writeValueAsString( toJsonObject( ids, authors, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt new file mode 100644 index 0000000000..d9e80aefa4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.normalizer + +data class NormalizedRelayUrl( + val url: String, +) : Comparable { + override fun compareTo(other: NormalizedRelayUrl) = url.compareTo(other.url) +} + +fun NormalizedRelayUrl.displayUrl() = + url + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/") + +fun NormalizedRelayUrl.toHttp() = + if (url.startsWith("wss://")) { + "https${url.drop(3)}" + } else if (url.startsWith("ws://")) { + "https${url.drop(2)}" + } else { + "https://$url" + } + +fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") + +fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt new file mode 100644 index 0000000000..11b8c67b39 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.normalizer + +import android.util.Log +import androidx.collection.LruCache +import kotlinx.coroutines.CancellationException +import org.czeal.rfc3986.URIReference +import kotlin.contracts.ExperimentalContracts + +val normalizedUrls = LruCache(5000) + +class RelayUrlNormalizer { + companion object { + fun isLocalHost(url: String) = + url.contains("127.0.0.1") || + url.contains("localhost") || + url.contains("//umbrel:") || + url.contains("192.168.") || + url.contains(".local:") || + url.contains(".local/") + + fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + + fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' + + fun isRelaySchemePrefixSecure(url: String) = url[2] == 's' && url[3] == ':' && url[4] == '/' && url[5] == '/' && url[6] != '/' + + fun isRelaySchemePrefixInsecure(url: String) = url[2] == ':' && url[3] == '/' && url[4] == '/' && url[5] != '/' + + fun isHttpPrefix(url: String) = url.length > 8 && url[0] == 'h' && url[1] == 't' && url[2] == 't' && url[3] == 'p' + + fun isHttpSSuffix(url: String) = url[4] == 's' && url[5] == ':' && url[6] == '/' && url[7] == '/' + + fun isHttpSuffix(url: String) = url[4] == ':' && url[5] == '/' && url[6] == '/' + + fun isRelayUrl(url: String): Boolean { + if (url.length < 3) return false + + val trimmed = + if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) { + url.trim() + } else { + url + } + + // fast + if (isRelaySchemePrefix(trimmed)) { + if (isRelaySchemePrefixSecure(trimmed)) { + return true + } else if (isRelaySchemePrefixInsecure(trimmed)) { + return true + } + } + + return false + } + + private fun norm(url: String) = + NormalizedRelayUrl( + URIReference + .parse(url) + .normalize() + .toString() + .intern(), + ) + + @OptIn(ExperimentalContracts::class) + fun fix(url: String): String? { + if (url.length < 3) return null + + val trimmed = + if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) { + url.trim() + } else { + url + } + + // fast for good wss:// urls + if (isRelaySchemePrefix(trimmed)) { + if (isRelaySchemePrefixSecure(trimmed) || isRelaySchemePrefixInsecure(trimmed)) { + return trimmed + } + } + + // fast for good https:// urls + if (isHttpPrefix(trimmed)) { + if (isHttpSSuffix(trimmed)) { + // https:// + return "wss://${trimmed.drop(8)}" + } else if (isHttpSuffix(trimmed)) { + // http:// + return "ws://${trimmed.drop(7)}" + } + } + + // fast for good ww:// urls + if (trimmed.startsWith("ww://")) { + return "wss://${trimmed.drop(5)}" + } + + // fast for good ww:// urls + if (trimmed.startsWith("was://")) { + return "wss://${trimmed.drop(6)}" + } + + // fast for good ww:// urls + if (trimmed.startsWith("Wws://")) { + return "wss://${trimmed.drop(6)}" + } + + if (trimmed.contains("://")) { + // some other scheme we cannot connect to. + Log.w("RelayUrlNormalizer", "Rejected relay URL: $url") + return null + } + + return if (isOnion(trimmed) || isLocalHost(trimmed)) { + "ws://$trimmed" + } else { + "wss://$trimmed" + } + } + + fun normalize(url: String): NormalizedRelayUrl { + normalizedUrls[url]?.let { return it } + + return try { + val fixed = fix(url) ?: return NormalizedRelayUrl(url) + val normalized = norm(fixed) + normalizedUrls.put(url, normalized) + normalized + } catch (e: Exception) { + NormalizedRelayUrl(url) + } + } + + fun normalizeOrNull(url: String): NormalizedRelayUrl? { + if (url.isEmpty()) return null + normalizedUrls[url]?.let { return it } + + return try { + val fixed = fix(url) + if (fixed != null) { + val normalized = norm(fixed) + normalizedUrls.put(url, normalized) + return normalized + } else { + Log.w("NormalizedRelayUrl", "Rejected Error $url") + null + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NormalizedRelayUrl", "Rejected Error $url") + null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt index a11466a417..090617cba9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebSocket { + fun needsReconnect(): Boolean + fun connect() - fun cancel() + fun disconnect() fun send(msg: String): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt index 32f8808b30..acebf8d6b7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt @@ -40,6 +40,7 @@ interface WebSocketListener { fun onFailure( t: Throwable, + code: Int?, response: String?, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt index ca813cff6f..b2ca9aa9ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.sockets +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + interface WebsocketBuilder { fun build( - url: String, + url: NormalizedRelayUrl, out: WebSocketListener, ): WebSocket } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt index 6b7a0b9781..25a2196d67 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.core.tagArray -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.TimeUtils class EventTemplate( @@ -36,10 +36,10 @@ class EventTemplate( val tags: TagArray, val content: String, ) { - fun toJson(): String = EventMapper.mapper.writeValueAsString(this) + fun toJson(): String = JsonMapper.mapper.writeValueAsString(this) companion object { - fun fromJson(json: String): EventTemplate = EventTemplateManualDeserializer.fromJson(EventMapper.mapper.readTree(json)) + fun fromJson(json: String): EventTemplate = EventTemplateManualDeserializer.fromJson(JsonMapper.mapper.readTree(json)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index a36474c752..40dbd3a165 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -20,10 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.signers -import com.vitorpamplona.quartz.EventFactory import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -31,104 +29,51 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent abstract class NostrSigner( val pubKey: HexKey, ) { - fun sign( - ev: EventTemplate, - onReady: (T) -> Unit, - ) = sign(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) + abstract fun isWriteable(): Boolean - abstract fun sign( + suspend fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) + + abstract suspend fun sign( createdAt: Long, kind: Int, tags: Array>, content: String, - onReady: (T) -> Unit, - ) + ): T - abstract fun nip04Encrypt( - decryptedContent: String, + abstract suspend fun nip04Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip04Decrypt( - encryptedContent: String, + abstract suspend fun nip04Decrypt( + ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip44Encrypt( - decryptedContent: String, + abstract suspend fun nip44Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip44Decrypt( + abstract suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String + + abstract suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent + + abstract suspend fun deriveKey(nonce: HexKey): HexKey + + suspend fun decrypt( encryptedContent: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String { + if (encryptedContent.isBlank()) throw SignerExceptions.NothingToDecrypt() - abstract fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) - - abstract fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) - - fun decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (EncryptedInfo.isNIP04(encryptedContent)) { - nip04Decrypt(encryptedContent, fromPublicKey, onReady) + return if (EncryptedInfo.isNIP04(encryptedContent)) { + nip04Decrypt(encryptedContent, fromPublicKey) } else { - nip44Decrypt(encryptedContent, fromPublicKey, onReady) + nip44Decrypt(encryptedContent, fromPublicKey) } } - - fun assembleRumor( - ev: EventTemplate, - onReady: (T) -> Unit, - ) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) - - fun assembleRumor( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - onReady( - EventFactory.create( - id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) as T, - ) - } - - fun assembleRumor(ev: EventTemplate) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content) - - fun assembleRumor( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ) = EventFactory.create( - id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) as T } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index 53c37c1cf2..a9fc14f093 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -26,65 +26,76 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.CancellationException class NostrSignerInternal( val keyPair: KeyPair, ) : NostrSigner(keyPair.pubKey.toHexKey()) { val signerSync = NostrSignerSync(keyPair) - override fun sign( + override fun isWriteable(): Boolean = keyPair.privKey != null + + inline fun runWrapErrors(action: () -> T): T = + try { + action() + } catch (e: Exception) { + if (e is CancellationException) throw e + if (e is SignerExceptions) throw e + throw SignerExceptions.CouldNotPerformException("Could not perform the operation", e) + } + + override suspend fun sign( createdAt: Long, kind: Int, tags: Array>, content: String, - onReady: (T) -> Unit, - ) { - signerSync.sign(createdAt, kind, tags, content)?.let { onReady(it) } - } + ): T = + runWrapErrors { + signerSync.sign(createdAt, kind, tags, content) + } - override fun nip04Encrypt( - decryptedContent: String, + override suspend fun nip04Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip04Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip04Encrypt(plaintext, toPublicKey) + } - override fun nip04Decrypt( - encryptedContent: String, + override suspend fun nip04Decrypt( + ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip04Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip04Decrypt(ciphertext, fromPublicKey) + } - override fun nip44Encrypt( - decryptedContent: String, + override suspend fun nip44Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip44Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip44Encrypt(plaintext, toPublicKey) + } - override fun nip44Decrypt( - encryptedContent: String, + override suspend fun nip44Decrypt( + ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip44Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) } + ): String = + runWrapErrors { + signerSync.nip44Decrypt(ciphertext, fromPublicKey) + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt() + + return runWrapErrors { + signerSync.decryptZapEvent(event) + } } - override fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - signerSync.decryptZapEvent(event)?.let { onReady(it) } - } - - override fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) { - signerSync.deriveKey(nonce)?.let { onReady(it) } - } + override suspend fun deriveKey(nonce: HexKey): HexKey = + runWrapErrors { + signerSync.deriveKey(nonce) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt index 51f3f051aa..5969ca622a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.signers -import android.util.Log import com.vitorpamplona.quartz.experimental.decoupling.EncryptionKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -28,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -45,8 +45,8 @@ class NostrSignerSync( kind: Int, tags: Array>, content: String, - ): T? { - if (keyPair.privKey == null) return null + ): T { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() return if (isUnsignedPrivateZapEvent(kind, tags)) { // this is a private zap @@ -68,71 +68,80 @@ class NostrSignerSync( kind: Int, tags: Array>, content: String, - ): T? { - if (keyPair.privKey == null) return null + ): T { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() return EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, keyPair.privKey) } fun nip04Encrypt( - decryptedContent: String, + plaintext: String, toPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (plaintext.isBlank()) return "" return Nip04.encrypt( - decryptedContent, + plaintext, keyPair.privKey, toPublicKey.hexToByteArray(), ) } fun nip04Decrypt( - encryptedContent: String, + ciphertext: String, fromPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() - return try { - Nip04.decrypt(encryptedContent, keyPair.privKey, fromPublicKey.hexToByteArray()) - } catch (e: Exception) { - Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") - null - } + return Nip04.decrypt(ciphertext, keyPair.privKey, fromPublicKey.hexToByteArray()) } fun nip44Encrypt( - decryptedContent: String, + plaintext: String, toPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (plaintext.isBlank()) return "" return Nip44 .encrypt( - decryptedContent, + plaintext, keyPair.privKey, toPublicKey.hexToByteArray(), ).encodePayload() } fun nip44Decrypt( - encryptedContent: String, + ciphertext: String, fromPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() return Nip44.decrypt( - payload = encryptedContent, + payload = ciphertext, privateKey = keyPair.privKey, pubKey = fromPublicKey.hexToByteArray(), ) } - fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent? = PrivateZapRequestBuilder().decryptZapEvent(event, this) + fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) - fun deriveKey(nonce: HexKey): HexKey? { - if (keyPair.privKey == null) return null + fun deriveKey(nonce: HexKey): HexKey { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() return EncryptionKeyDerivation.derivePrivateKey(keyPair.privKey, nonce.hexToByteArray()).toHexKey() } + + suspend fun decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + ): String = + if (EncryptedInfo.isNIP04(encryptedContent)) { + nip04Decrypt(encryptedContent, fromPublicKey) + } else { + nip44Decrypt(encryptedContent, fromPublicKey) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt new file mode 100644 index 0000000000..317a60bcea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers + +sealed class SignerExceptions( + msg: String, + cause: Throwable? = null, +) : Exception(msg, cause) { + class ReadOnlyException : SignerExceptions("Signer is read-only") + + class UnauthorizedDecryptionException : SignerExceptions("Couldn't not decrypt the contents of this event.") + + class NothingToDecrypt : SignerExceptions("Ciphertext is Empty") + + class AutomaticallyUnauthorizedException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class ManuallyUnauthorizedException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class TimedOutException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class CouldNotPerformException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg, cause) + + class RunningOnBackgroundWithoutAutomaticPermissionException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class SignerNotFoundException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt new file mode 100644 index 0000000000..cda531884c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers.caches + +sealed interface CacheResults { + class Success( + val value: T, + ) : CacheResults + + class DontTryAgain : CacheResults + + class NeedsForegroundActivityToTryAgain( + val after: Long, + ) : CacheResults + + class CanTryAgain( + val after: Long, + ) : CacheResults +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/DecryptCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/DecryptCache.kt new file mode 100644 index 0000000000..eb84002495 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/DecryptCache.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers.caches + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal +import com.vitorpamplona.quartz.utils.TimeUtils + +abstract class DecryptCache( + val signer: NostrSigner, +) { + var cache: CacheResults = CacheResults.CanTryAgain(0) + + fun preload(result: T) { + cache = CacheResults.Success(result) + } + + abstract suspend fun decryptAndParse( + event: I, + signer: NostrSigner, + ): T + + private suspend fun performDecrypt(input: I): T? { + try { + val response = decryptAndParse(input, signer) + cache = CacheResults.Success(response) + return response + } catch (e: SignerExceptions.NothingToDecrypt) { + Log.w("DecryptCache", "Nothing to decrypt", e) + // ciphertext is blank. Cancels everything. + cache = CacheResults.DontTryAgain() + } catch (e: SignerExceptions.AutomaticallyUnauthorizedException) { + Log.w("DecryptCache", "NothAutomaticallyUnauthorizedException", e) + // User has rejected this permission. Don't try again. + cache = CacheResults.DontTryAgain() + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + Log.w("DecryptCache", "ManuallyUnauthorizedException", e) + // User has rejected this permission. Don't try again. + cache = CacheResults.CanTryAgain(TimeUtils.tenSecondsFromNow()) + } catch (e: SignerExceptions.TimedOutException) { + Log.w("DecryptCache", "TimedOutException", e) + // User has did not reply to the approval request. Ignore until later time. + cache = CacheResults.CanTryAgain(TimeUtils.tenSecondsFromNow()) + } catch (e: SignerExceptions.CouldNotPerformException) { + // Log.w("DecryptCache", "CouldNotPerformException", e) + // Decryption failed. This key might not be able to decrypt anything. Don't try again. + cache = CacheResults.DontTryAgain() + } catch (e: SignerExceptions.SignerNotFoundException) { + Log.w("DecryptCache", "SignerNotFoundException", e) + // Signer app was deleted. Not sure what to to. It should probably log off. + cache = CacheResults.DontTryAgain() + } catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) { + Log.w("DecryptCache", "RunningOnBackgroundWithoutAutomaticPermissionException", e) + // App received a notifications, asked the signer to decrypt but the permission was not automatic. + // It needs the interface but does not have it. It needs to wait for an activity. + cache = CacheResults.NeedsForegroundActivityToTryAgain(TimeUtils.tenSecondsFromNow()) + } catch (e: com.fasterxml.jackson.core.JsonParseException) { + Log.w("DecryptCache", "JsonParseException", e) + // Decryption failed. This key might not be able to decrypt anything. Don't try again. + cache = CacheResults.DontTryAgain() + } catch (e: IllegalStateException) { + Log.w("DecryptCache", "IllegalStateException", e) + cache = CacheResults.DontTryAgain() + } catch (e: IllegalArgumentException) { + Log.w("DecryptCache", "IllegalArgumentException", e) + cache = CacheResults.DontTryAgain() + } + return null + } + + fun cached(): T? { + val cachedResult = cache + return if (cachedResult is CacheResults.Success) { + cachedResult.value + } else { + null + } + } + + suspend fun decrypt(input: I): T? { + val cachedResult = cache + return when (cachedResult) { + is CacheResults.Success -> cachedResult.value + is CacheResults.DontTryAgain -> null + is CacheResults.CanTryAgain -> { + if (TimeUtils.now() > cachedResult.after) { + performDecrypt(input) + } else { + null + } + } + is CacheResults.NeedsForegroundActivityToTryAgain<*> -> { + if (TimeUtils.now() > cachedResult.after && (signer !is NostrSignerExternal || signer.hasForegroundActivity())) { + performDecrypt(input) + } else { + null + } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md new file mode 100644 index 0000000000..c8084f0a29 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md @@ -0,0 +1,193 @@ +# Event Store Module + +This module implements an **Event Store** with nostr-native queries. + +The goal was not to make the fastest database, since there could be multiple optimizations made if +consistency can be sacrificed, but a database that will never crash and never go corrupt. + +## Responsibilities + +- **Storage & Retrieval**: + Stores Nostr events and enables retrieval using Nostr filters + +- **Replaceable Events**: + - Old versions are removed when newer versions arrive. + - Old versions are blocked if newer versions exist. + +- **Ephemeral Events** + - Ephemeral events never stored. + +- **NIP-40 Expirations** + - Manages expiration timestamps and prunes expired events. + - Blocks expired events from being reinserted + +- **NIP-09 Deletion Events** + - Deletes by event id + - Deletes by address until the `created_at` + - Blocks deleted events from being re-inserted. + +- **NIP-62 Right to Vanish** + - Supports deleting an entire user until the `created_at` for enhanced privacy + +- **NIP-50 Full Text Search**: + - Implements content indexing and full text search supporting rich queries over event content. + +- **Immutable Tables** + Triggers ensure event immutability. + +## Indexing Strategy + +The store indexes events using five dedicated tables: +- `event_headers`: stores the canonical event fields. +- `event_tags`: indexes tag values for fast filtering on tag-based queries. +- `event_fts`: for the content of full text search +- `event_expirations`: to control when expired events must be deleted. +- `event_vanish`: to control up to when vanished accounts must be blocked. + +SQL triggers ensure the **immutability of stored events**, preventing accidental or intentional +modifications. + +## Querying + +This module supports optimized query planning, producing efficient SQL for multi-filter evaluation +across fields and tags, including `limit` clauses per filter: + +For instance, the following filter: +```kotlin +store.query( + listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey), kinds = listOf(1, 1111), search = "keywords", limit = 100), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ) +) +``` + +Becomes + +```sql +SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers +INNER JOIN ( + SELECT event_headers.row_id AS row_id + FROM event_headers + ORDER BY created_at DESC,id ASC + LIMIT 10 + + UNION + + SELECT event_headers.row_id AS row_id + FROM event_headers + INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id + WHERE event_headers.kind IN (1, 1111) + AND event_headers.pubkey = hexkey + AND event_fts MATCH "keywords" + ORDER BY created_at DESC, id ASC + LIMIT 100 + + UNION + + SELECT event_headers.row_id AS row_id + FROM event_headers + INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id + WHERE event_headers.kind = 20 + AND event_fts MATCH "cats" + ORDER BY created_at DESC,id ASC + LIMIT 30 +) AS filtered ON event_headers.row_id = filtered.row_id +ORDER BY created_at DESC,id +``` + +The union operations support complex filter lists while avoiding redundant data fetching and +duplicated outstreams. + +## How to Use + +The `EventStore` class provides a high-level interface for interacting with the event database. +It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning. + +### Initialization + +To initialize the `EventStore` in your Application class: + +```kotlin +val eventStore = EventStore(context, "dbname.db", relayUrlIdentifier) +``` + +### Querying Events + +To query events, use the `query` method with one or more `Filter` objects: + +```kotlin +val filters = listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey)) +) + +val events = eventStore.query(filters) +``` + +or to receive events as the cursor sends: + +```kotlin +val filters = listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey)) +) + +eventStore.query(filters) { event -> + // do something +} +``` + +`count` and `delete` also accept one or more filters. + +### Inserting Events + +Insert a single event using the `insert` method: + +```kotlin +eventStore.insert(event) +``` + +### Deleting Events + +Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually +delete an event by ID, use the `delete` method: + +```kotlin +eventStore.delete(event.id) +``` + +### Full-Text Search + +The store supports full-text search using the `search` parameter in filters: + +```kotlin +val result = eventStore.query(Filter(search = "bitcoin", limit = 20)) +``` + +This will match any event whose content contains "bitcoin", returning the most recent 20 results. + +### Periodic cleanup of expired events. + +The store exposes a `deleteExpiredEvents` to be used in a periodic clean up procedure. Users +should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We +recommend a 15-minute window to remove recently expired events from the database. + +Here's an example of a Worker that should be added to your application class. + +```kotlin +class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { + override fun doWork(): Result { + YourApplication.store.deleteExpiredEvents() + return Result.success() + } +} + +fun schedulePeriodicWork(context: Context) { + val periodicWorkRequest = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + + WorkManager.getInstance(context).enqueue(periodicWorkRequest) +} +``` diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt new file mode 100644 index 0000000000..b89d2a14e2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class AddressableModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE UNIQUE INDEX addressable_idx + ON event_headers (kind, pubkey, d_tag) + WHERE kind >= 30000 AND kind < 40000 + """.trimIndent(), + ) + + // Rejects old addressables + db.execSQL( + """ + CREATE TRIGGER reject_older_addressable_event + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 30000 AND NEW.kind < 40000) + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag + ); + + DELETE FROM event_tags + WHERE event_header_row_id in ( + SELECT row_id FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag + ); + + DELETE FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag; + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt new file mode 100644 index 0000000000..3a20e47cae --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent + +class DeletionRequestModule { + fun create(db: SQLiteDatabase) { + // rejects deleted events. + db.execSQL( + """ + CREATE TRIGGER reject_deleted_events + BEFORE INSERT ON event_headers + FOR EACH ROW + BEGIN + -- Check for ID-based deletion record + SELECT RAISE(ABORT, 'blocked: a deletion event for this event id exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = 5 AND + event_headers.pubkey = NEW.pubkey AND + event_tags.tag_name = 'e' AND + event_tags.tag_value = NEW.id + ); + + -- Check for address-based deletion record + SELECT RAISE(ABORT, 'blocked: a deletion event for this address exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = 5 AND + event_headers.pubkey = NEW.pubkey AND + event_tags.tag_name = 'a' AND + event_tags.tag_value = NEW.kind || ':' || NEW.pubkey || ':' || NEW.d_tag + ); + END; + """.trimIndent(), + ) + } + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is DeletionEvent) { + val idValues = event.deleteEventIds() + val idParams = idValues.joinToString(",") { "?" } + + val addresses = event.deleteAddresses() + val addressParams = addresses.joinToString(",") { "(?, ?)" } + val addressValues = addresses.flatMap { listOf(it.kind, it.dTag) } + + val whereClause = + if (idValues.isNotEmpty() && addresses.isNotEmpty()) { + "(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?" + } else if (idValues.isNotEmpty()) { + "id IN ($idParams) AND pubkey = ?" + } else if (addresses.isNotEmpty()) { + "(kind, d_tag) IN ($addressParams) AND pubkey = ?" + } else { + return + } + val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray() + + db.execSQL( + """ + DELETE FROM event_headers + WHERE $whereClause; + """.trimIndent(), + whereParams, + ) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt new file mode 100644 index 0000000000..011f114e01 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class EphemeralModule { + fun create(db: SQLiteDatabase) { + // Rejects all ephemeral events. + db.execSQL( + """ + CREATE TRIGGER reject_ephemeral_events + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 20000 AND NEW.kind < 30000) + BEGIN + SELECT RAISE(ABORT, 'blocked: cannot store ephemeral events'); + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt new file mode 100644 index 0000000000..d1a2e2436e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -0,0 +1,469 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where + +class EventIndexesModule( + val fts: FullTextSearchModule, +) { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_headers ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + d_tag TEXT, + tags TEXT NOT NULL, + content TEXT NOT NULL, + sig TEXT NOT NULL + ) + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TABLE event_tags ( + event_header_row_id INTEGER, + tag_name TEXT NOT NULL, + tag_value TEXT NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") + db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)") + db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)") + db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_name, tag_value)") + + // Prevent updates to maintain immutability + db.execSQL( + """ + CREATE TRIGGER event_headers_prevent_update + BEFORE UPDATE ON event_headers + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'Error: Updates are not allowed.'); + END; + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TRIGGER event_tags_prevent_update + BEFORE UPDATE ON event_tags + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'Error: Updates are not allowed.'); + END; + """.trimIndent(), + ) + } + + val sqlInsertHeader = + """ + INSERT INTO event_headers + (id, pubkey, created_at, kind, tags, content, sig, d_tag) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val sqlInsertTags = + """ + INSERT OR ROLLBACK INTO event_tags + (event_header_row_id, tag_name, tag_value) + VALUES + (?,?,?) + """.trimIndent() + + fun insert( + event: Event, + db: SQLiteDatabase, + ): Long { + val stmt = db.compileStatement(sqlInsertHeader) + stmt.bindString(1, event.id) + stmt.bindString(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.bindLong(4, event.kind.toLong()) + stmt.bindString(5, JsonMapper.mapper.writeValueAsString(event.tags)) + stmt.bindString(6, event.content) + stmt.bindString(7, event.sig) + if (event is AddressableEvent) { + stmt.bindString(8, event.dTag()) + } else { + stmt.bindNull(8) + } + val headerId = stmt.executeInsert() + + val tagsToIndex = event.indexableTags() + + if (tagsToIndex.isNotEmpty()) { + val sql = + buildString { + append(sqlInsertTags) + repeat(tagsToIndex.size - 1) { + append(",(?,?,?)") + } + } + + val stmtTags = db.compileStatement(sql) + + var index = 1 + tagsToIndex.forEach { tag -> + stmtTags.bindLong(index++, headerId) + stmtTags.bindString(index++, tag[0]) + stmtTags.bindString(index++, tag[1]) + } + + stmtTags.executeInsert() + } + + return headerId + } + + fun Event.indexableTags(): List { + val indexableTagNames = extraIndexableTagNames() + return if (indexableTagNames.isNotEmpty()) { + tags.filter { it.size >= 2 && (it[0].length == 1 || it[0] in indexableTagNames) } + } else { + tags.filter { it.size >= 2 && it[0].length == 1 } + } + } + + fun planQuery(filter: Filter): String { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return makeEverythingQuery() + + return makeQueryIn(rowIdSubQuery.sql) + } + + fun query( + filter: Filter, + db: SQLiteDatabase, + ): List { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQuery(makeEverythingQuery()) + + return db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args) + } + + fun query( + filter: Filter, + db: SQLiteDatabase, + onEach: (Event) -> Unit, + ) { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + + db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) + } + + fun planQuery(filters: List): String { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + if (rowIdSubQueries.isEmpty()) return makeEverythingQuery() + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + return makeQueryIn(unions) + } + + fun query( + filters: List, + db: SQLiteDatabase, + ): List { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery()) + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + return db.runQuery(makeQueryIn(unions), args) + } + + fun query( + filters: List, + db: SQLiteDatabase, + onEach: (Event) -> Unit, + ) { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + db.runQueryEmitting(makeQueryIn(unions), args, onEach) + } + + private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id" + + private fun makeQueryIn(rowIdQuery: String) = + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ($rowIdQuery) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent() + + private fun SQLiteDatabase.runQuery( + sql: String, + args: List = emptyList(), + ): List = + rawQuery(sql, args.toTypedArray()).use { cursor -> + parseResults(cursor) + } + + private fun parseResults(cursor: Cursor): List { + val events = ArrayList() + + while (cursor.moveToNext()) { + events.add( + EventFactory.create( + cursor.getString(0).intern(), + cursor.getString(1).intern(), + cursor.getLong(2), + cursor.getInt(3), + JsonMapper.mapper.readValue>>(cursor.getString(4)), + cursor.getString(5).intern(), + cursor.getString(6).intern(), + ), + ) + } + + return events + } + + private fun SQLiteDatabase.runQueryEmitting( + sql: String, + args: List = emptyList(), + onEach: (Event) -> Unit, + ) = rawQuery(sql, args.toTypedArray()).use { cursor -> + emitResults(cursor, onEach) + } + + private fun emitResults( + cursor: Cursor, + onEach: (Event) -> Unit, + ) { + while (cursor.moveToNext()) { + onEach( + EventFactory.create( + cursor.getString(0).intern(), + cursor.getString(1).intern(), + cursor.getLong(2), + cursor.getInt(3), + JsonMapper.mapper.readValue>>(cursor.getString(4)), + cursor.getString(5).intern(), + cursor.getString(6).intern(), + ), + ) + } + } + + // -------------- + // Counts + // ------------- + fun count( + filter: Filter, + db: SQLiteDatabase, + ): Int { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.countEverything() + + return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } + + fun count( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.countEverything() + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + return db.countIn(unions, args) + } + + private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") + + private fun SQLiteDatabase.countIn( + rowIdQuery: String, + args: List, + ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) + + private fun SQLiteDatabase.runCount( + sql: String, + args: List = emptyList(), + ): Int = + rawQuery(sql, args.toTypedArray()).use { cursor -> + cursor.moveToNext() + cursor.getInt(0) + } + + // -------------- + // Deletes + // ------------- + fun delete( + filter: Filter, + db: SQLiteDatabase, + ): Int? { + val rowIdQuery = prepareRowIDSubQueries(filter) ?: return null + return db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } + + fun delete( + filters: List, + db: SQLiteDatabase, + ): Int? { + val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubqueries.isEmpty()) return null + + val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubqueries.flatMap { it.args } + + return db.runDelete(unions, args) + } + + private fun SQLiteDatabase.runDelete( + sql: String, + args: List = emptyList(), + ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + + // ---------------------------- + // Inner row id selections + // ---------------------------- + fun prepareRowIDSubQueries(filter: Filter): RowIdSubQuery? { + if (!filter.isFilledFilter()) return null + + val hasHeaders = + with(filter) { + (ids != null && ids.isNotEmpty()) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (since != null) || + (until != null) || + (tags != null && tags.containsKey("d")) + } + + val hasSearch = (filter.search != null && filter.search.isNotBlank()) + + val projection = + buildString { + val anchorColumn: String + val joins = mutableListOf() + + if (hasHeaders) { + append("SELECT event_headers.row_id as row_id FROM event_headers") + anchorColumn = "event_headers.row_id" + + if (hasSearch) { + joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = $anchorColumn") + } + + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = $anchorColumn") + } + } + } else if (hasSearch) { + append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") + anchorColumn = "${fts.tableName}.${fts.eventHeaderRowIdName}" + + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = $anchorColumn") + } + } + } else { + // has only tags + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + if (isEmpty()) { + append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName") + } else { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id") + } + } + } + + if (isEmpty()) { + // only limit is present + append("SELECT event_headers.row_id as row_id FROM event_headers") + } + } + + if (joins.isNotEmpty()) { + append(" ${joins.joinToString(" ")}") + } + } + + val clause = + where { + filter.ids?.let { equalsOrIn("event_headers.id", it) } + filter.kinds?.let { equalsOrIn("event_headers.kind", it) } + filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } + filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } else { + equals("tag$tagName.tag_name", tagName) + equalsOrIn("tag$tagName.tag_value", tagValues) + } + } + + filter.search?.let { match(fts.tableName, it) } + } + + val whereClause = + if (filter.limit != null) { + "${clause.conditions} ORDER BY created_at DESC, id ASC LIMIT ${filter.limit}" + } else { + clause.conditions + } + + return RowIdSubQuery("$projection WHERE $whereClause", clause.args) + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_tags") + db.execSQL("DELETE FROM event_headers") + } + + class RowIdSubQuery( + val sql: String, + val args: List, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt new file mode 100644 index 0000000000..4794e4c7b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +class EventStore( + context: Context, + dbName: String? = "events.db", + val relayUrl: String? = "wss://quartz.local", +) { + val store = SQLiteEventStore(context, dbName, relayUrl) + + fun insert(event: Event) = store.insertEvent(event) + + fun query(filter: Filter) = store.query(filter) + + fun query(filters: List) = store.query(filters) + + fun query( + filter: Filter, + onEach: (Event) -> Unit, + ) = store.query(filter, onEach) + + fun query( + filters: List, + onEach: (Event) -> Unit, + ) = store.query(filters, onEach) + + fun count(filter: Filter) = store.count(filter) + + fun count(filters: List) = store.count(filters) + + fun delete(filter: Filter) = store.delete(filter) + + fun delete(filters: List) = store.delete(filters) + + fun deleteExpiredEvents() = store.deleteExpiredEvents() + + fun close() = store.close() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt new file mode 100644 index 0000000000..39f738712a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip40Expiration.expiration + +class ExpirationModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_expirations ( + event_header_row_id INTEGER, + expiration INTEGER NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + // Rejects old addressables + db.execSQL( + """ + CREATE TRIGGER reject_expired_events + BEFORE INSERT ON event_expirations + FOR EACH ROW + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'blocked: this event is expired') + WHERE NEW.expiration <= unixepoch(); + END; + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX events_exp_id ON event_expirations (event_header_row_id)") + } + + val insertExpiration = + """ + INSERT OR ROLLBACK INTO event_expirations (event_header_row_id, expiration) + VALUES (?, ?) + """.trimIndent() + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + val exp = event.expiration() + if (exp != null && exp > 0) { + val stmt = StatementCache.get(insertExpiration, db) + stmt.bindLong(1, headerId) + stmt.bindLong(2, exp) + stmt.executeInsert() + } + } + + val deleteExpiredEvents = + """ + DELETE FROM event_headers + WHERE row_id IN ( + SELECT event_expirations.event_header_row_id FROM event_expirations + WHERE event_expirations.expiration < unixepoch() + ); + """.trimIndent() + + fun deleteExpiredEvents(db: SQLiteDatabase) { + StatementCache.get(deleteExpiredEvents, db).execute() + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_expirations") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt new file mode 100644 index 0000000000..855d9cb99a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +class FullTextSearchModule { + val tableName = "event_fts" + val eventHeaderRowIdName = "event_header_row_id" + val contentName = "content" + + fun create(db: SQLiteDatabase) { + val ftsVersion = FullTextSearchModule().versionFinder(db) + db.execSQL( + """ + CREATE VIRTUAL TABLE $tableName + USING fts$ftsVersion($eventHeaderRowIdName, $contentName) + """, + ) + + // Foreign key cleanup for full text search + db.execSQL( + """ + CREATE TRIGGER fts_foreign_key + AFTER DELETE ON event_headers + FOR EACH ROW + BEGIN + DELETE FROM $tableName + WHERE old.row_id = $tableName.$eventHeaderRowIdName; + END; + """, + ) + } + + val insertFTS = + """ + INSERT OR ROLLBACK INTO $tableName ($eventHeaderRowIdName, $contentName) + VALUES (?, ?) + """.trimIndent() + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is SearchableEvent) { + val stmt = StatementCache.get(insertFTS, db) + stmt.bindLong(1, headerId) + stmt.bindString(2, event.indexableContent()) + stmt.executeInsert() + } + } + + fun versionFinder(db: SQLiteDatabase): Int = + try { + try { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)") + 5 + } catch (e: SQLiteException) { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts4 USING fts4(dummy)") + 4 + } + } catch (e: SQLiteException) { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts3 USING fts3(dummy)") + 3 + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_fts") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt new file mode 100644 index 0000000000..688aff89d2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class ReplaceableModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE UNIQUE INDEX replaceable_idx + ON event_headers (kind, pubkey) + WHERE (kind >= 10000 AND kind < 20000) OR (kind IN (0, 3)) + """.trimIndent(), + ) + + // Rejects old replaceables + db.execSQL( + """ + CREATE TRIGGER reject_older_replaceable_event + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3)) + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey + ); + + DELETE FROM event_tags + WHERE event_header_row_id in ( + SELECT row_id FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey + ); + + -- Delete older records if this is the newest + DELETE FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt new file mode 100644 index 0000000000..715caeaba7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent + +class RightToVanishModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_vanish ( + event_header_row_id INTEGER, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey)") + + db.execSQL( + """ + CREATE TRIGGER delete_older_event_vanish + BEFORE INSERT ON event_vanish + FOR EACH ROW + BEGIN + -- Delete older records if this is the newest + DELETE FROM event_vanish + WHERE + event_vanish.created_at < NEW.created_at AND + event_vanish.pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TRIGGER delete_events_on_event_vanish + AFTER INSERT ON event_vanish + FOR EACH ROW + BEGIN + DELETE FROM event_headers WHERE created_at < NEW.created_at AND pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + + // reject new events inside a right to vanish request + db.execSQL( + """ + CREATE TRIGGER reject_events_on_event_vanish + BEFORE INSERT ON event_headers + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'blocked: a request to vanish event exists') + WHERE EXISTS ( + SELECT 1 FROM event_vanish + WHERE + event_vanish.created_at >= NEW.created_at AND + event_vanish.pubkey = NEW.pubkey + ); + END; + """.trimIndent(), + ) + } + + val insertRTV = + """ + INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey, created_at) + VALUES (?, ?, ?) + """.trimIndent() + + fun insert( + event: Event, + relayUrl: String?, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { + val stmt = StatementCache.get(insertRTV, db) + stmt.bindLong(1, headerId) + stmt.bindString(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.executeInsert() + } + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_vanish") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt new file mode 100644 index 0000000000..3d80a48988 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.core.database.sqlite.transaction +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip40Expiration.isExpired + +class SQLiteEventStore( + context: Context, + dbName: String? = "events.db", + val relayUrl: String? = null, +) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { + companion object { + const val DATABASE_VERSION = 1 + } + + val fullTextSearchModule = FullTextSearchModule() + val eventIndexModule = EventIndexesModule(fullTextSearchModule) + + val replaceableModule = ReplaceableModule() + val addressableModule = AddressableModule() + val ephemeralModule = EphemeralModule() + + val deletionModule = DeletionRequestModule() + val expirationModule = ExpirationModule() + val rightToVanishModule = RightToVanishModule() + + override fun onConfigure(db: SQLiteDatabase) { + super.onConfigure(db) + + // makes sure the FKs are sane + db.setForeignKeyConstraintsEnabled(true) + + // SQLite implements mutations by appending them to a log, which it occasionally + // compacts into the database. This is called Write-Ahead Logging (WAL) + db.enableWriteAheadLogging() + + // The DB can be corrupted if the OS is shutdown before sync, which generally + // doesn't happen on Android + db.execSQL("PRAGMA synchronous = OFF") + } + + override fun onCreate(db: SQLiteDatabase) { + eventIndexModule.create(db) + replaceableModule.create(db) + addressableModule.create(db) + ephemeralModule.create(db) + deletionModule.create(db) + expirationModule.create(db) + rightToVanishModule.create(db) + fullTextSearchModule.create(db) + } + + override fun onUpgrade( + db: SQLiteDatabase, + oldVersion: Int, + newVersion: Int, + ) {} + + fun clearDB() { + val db = writableDatabase + fullTextSearchModule.deleteAll(db) + rightToVanishModule.deleteAll(db) + expirationModule.deleteAll(db) + eventIndexModule.deleteAll(db) + } + + fun insertEvent(event: Event): Boolean { + if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return false + + val db = writableDatabase + db.transaction { + val headerId = eventIndexModule.insert(event, db) + deletionModule.insert(event, headerId, db) + expirationModule.insert(event, headerId, db) + fullTextSearchModule.insert(event, headerId, db) + rightToVanishModule.insert(event, relayUrl, headerId, db) + } + return true + } + + fun query(filter: Filter): List = eventIndexModule.query(filter, readableDatabase) + + fun query(filters: List): List = eventIndexModule.query(filters, readableDatabase) + + fun query( + filter: Filter, + onEach: (Event) -> Unit, + ) = eventIndexModule.query(filter, readableDatabase, onEach) + + fun query( + filters: List, + onEach: (Event) -> Unit, + ) = eventIndexModule.query(filters, readableDatabase, onEach) + + fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase) + + fun count(filters: List): Int = eventIndexModule.count(filters, readableDatabase) + + fun delete(filter: Filter): Int? = eventIndexModule.delete(filter, writableDatabase) + + fun delete(filters: List): Int? = eventIndexModule.delete(filters, writableDatabase) + + fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) + + fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt new file mode 100644 index 0000000000..8d30784532 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteStatement +import android.util.LruCache + +object StatementCache { + data class StatementKey( + val sql: String, + val dbHashcode: Int, + ) + + val cachedStatements = LruCache(10) + + fun get( + sql: String, + db: SQLiteDatabase, + ): SQLiteStatement { + val key = StatementKey(sql, db.hashCode()) + val cached = cachedStatements.get(key) + return if (cached != null) { + cached.clearBindings() + cached + } else { + val stat = db.compileStatement(sql) + cachedStatements.put(key, stat) + stat + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt new file mode 100644 index 0000000000..c2cd3a6e61 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql + +sealed class Condition { + data class Equals( + val column: String, + val value: Any?, + ) : Condition() + + data class NotEquals( + val column: String, + val value: Any?, + ) : Condition() + + data class GreaterThan( + val column: String, + val value: Any, + ) : Condition() + + data class GreaterThanOrEquals( + val column: String, + val value: Any, + ) : Condition() + + data class LessThan( + val column: String, + val value: Any, + ) : Condition() + + data class LessThanOrEquals( + val column: String, + val value: Any, + ) : Condition() + + data class Like( + val column: String, + val value: String, + ) : Condition() + + data class Match( + val table: String, + val value: String, + ) : Condition() + + data class IsNull( + val column: String, + ) : Condition() + + data class IsNotNull( + val column: String, + ) : Condition() + + data class In( + val column: String, + val values: List, + ) : Condition() + + data class And( + val conditions: List, + ) : Condition() + + data class Or( + val conditions: List, + ) : Condition() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt new file mode 100644 index 0000000000..7e898c2445 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql + +class SqlSelectionBuilder( + private val condition: Condition, +) { + private val selectionArgs = mutableListOf() + + fun build(): WhereClause { + selectionArgs.clear() // Clear previous args for a fresh build + val conditions = buildCondition(condition) + return WhereClause(conditions, selectionArgs) + } + + /** + * Recursively builds the SQL string for a given condition. + * @param cond The [Condition] to build the SQL string for. + * @return The SQL string representation of the condition. + */ + private fun buildCondition(cond: Condition): String = + when (cond) { + is Condition.Equals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} = ?" + } + is Condition.NotEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} != ?" + } + is Condition.GreaterThan -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} > ?" + } + is Condition.GreaterThanOrEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} >= ?" + } + is Condition.LessThan -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} < ?" + } + is Condition.LessThanOrEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} <= ?" + } + is Condition.Like -> { + selectionArgs.add(cond.value) + "${cond.column} LIKE ?" + } + is Condition.Match -> { + selectionArgs.add(cond.value) + "${cond.table} MATCH ?" + } + is Condition.IsNull -> { + "${cond.column} IS NULL" + } + is Condition.IsNotNull -> { + "${cond.column} IS NOT NULL" + } + is Condition.In -> { + if (cond.values.isEmpty()) { + // Handle empty IN clause gracefully, perhaps by making it always false + // or throwing an error, depending on desired behavior. + // For now, let's make it an always false condition to avoid SQL errors. + "1 = 0" // Always false + } else { + val placeholders = cond.values.joinToString(", ") { "?" } + cond.values.forEach { selectionArgs.add(it.toString()) } + "${cond.column} IN ($placeholders)" + } + } + is Condition.And -> { + if (cond.conditions.isEmpty()) { + "1 = 1" // Always true for an empty AND + } else { + cond.conditions.joinToString(" AND ") { "(${buildCondition(it)})" } + } + } + is Condition.Or -> { + if (cond.conditions.isEmpty()) { + "1 = 0" // Always false for an empty OR + } else { + cond.conditions.joinToString(" OR ") { "(${buildCondition(it)})" } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt new file mode 100644 index 0000000000..817ab0ea23 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql + +class WhereClauseBuilder { + private val conditions = mutableListOf() + + fun equals( + column: String, + value: Any?, + ) = apply { conditions.add(Condition.Equals(column, value)) } + + fun notEquals( + column: String, + value: Any?, + ) = apply { conditions.add(Condition.NotEquals(column, value)) } + + fun greaterThan( + column: String, + value: Any, + ) = apply { conditions.add(Condition.GreaterThan(column, value)) } + + fun greaterThanOrEquals( + column: String, + value: Any, + ) = apply { conditions.add(Condition.GreaterThanOrEquals(column, value)) } + + fun lessThan( + column: String, + value: Any, + ) = apply { conditions.add(Condition.LessThan(column, value)) } + + fun lessThanOrEquals( + column: String, + value: Any, + ) = apply { conditions.add(Condition.LessThanOrEquals(column, value)) } + + fun like( + column: String, + pattern: String, + ) = apply { conditions.add(Condition.Like(column, pattern)) } + + fun match( + table: String, + pattern: String, + ) = apply { conditions.add(Condition.Match(table, pattern)) } + + fun isNull(column: String) = apply { conditions.add(Condition.IsNull(column)) } + + fun isNotNull(column: String) = apply { conditions.add(Condition.IsNotNull(column)) } + + fun isIn( + column: String, + values: List, + ) = apply { conditions.add(Condition.In(column, values)) } + + fun equalsOrIn( + column: String, + values: List, + ) = apply { + if (values.size == 1) { + equals(column, values.first()) + } else { + isIn(column, values) + } + } + + fun and(block: WhereClauseBuilder.() -> Unit) = + apply { + val builder = WhereClauseBuilder().apply(block) + val builtCondition = builder.build() + if (builtCondition != null) { + conditions.add(builtCondition) + } + } + + fun or(block: WhereClauseBuilder.() -> Unit) = + apply { + val builder = WhereClauseBuilder().apply(block) + val builtCondition = builder.build() + if (builtCondition != null) { + conditions.add(builtCondition) + } + } + + fun build(): Condition? = + when (conditions.size) { + 0 -> null + 1 -> conditions.first() + else -> Condition.And(conditions.toList()) + } +} + +fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { + val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList()) + return SqlSelectionBuilder(condition).build() +} + +class WhereClause( + val conditions: String, + val args: List, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt index 8ceb598a50..74a0ac49bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt @@ -24,33 +24,32 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class ATag( val kind: Int, - val pubKeyHex: String, - val dTag: String, - val relay: String? = null, + val pubKeyHex: HexKey, + val dTag: String = "", + val relay: NormalizedRelayUrl? = null, ) { - constructor(address: Address, relayHint: String? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) + constructor(address: Address, relayHint: NormalizedRelayUrl? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) fun countMemory(): Long = 5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) 8L + // kind pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toTag() = Address.assemble(kind, pubKeyHex, dTag) - fun toATagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) - - fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", toTag(), relay) + fun toATagArray() = assemble(toTag(), relay) companion object { const val TAG_NAME = "a" @@ -109,7 +108,14 @@ data class ATag( fun parse( aTagId: String, relay: String?, - ) = Address.parse(aTagId)?.let { ATag(it.kind, it.pubKeyHex, it.dTag, relay) } + ) = Address.parse(aTagId)?.let { + ATag( + it.kind, + it.pubKeyHex, + it.dTag, + relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }, + ) + } @JvmStatic fun parse(tag: Array): ATag? { @@ -150,21 +156,31 @@ data class ATag( ensure(tag[1].isNotEmpty()) { return null } ensure(tag[1].contains(':')) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( aTagId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt index c3ccd850a4..c2ad939015 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt @@ -22,15 +22,18 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class Address( - val kind: Int, + val kind: Kind, val pubKeyHex: HexKey, - val dTag: String, -) { + val dTag: String = "", +) : Comparable
{ fun toValue() = assemble(kind, pubKeyHex, dTag) fun countMemory(): Long = @@ -39,27 +42,53 @@ data class Address( pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + override fun compareTo(other: Address): Int { + val result = kind.compareTo(other.kind) + if (result == 0) { + val result2 = pubKeyHex.compareTo(other.pubKeyHex) + if (result2 == 0) { + return dTag.compareTo(other.dTag) + } else { + return result2 + } + } else { + return result + } + } + companion object { fun assemble( kind: Int, pubKeyHex: HexKey, - dTag: String, + dTag: String = "", ) = "$kind:$pubKeyHex:$dTag" @JvmStatic - fun parse(addressId: String): Address? = - try { + fun parse(addressId: String): Address? { + if (addressId.isBlank()) return null + return try { val parts = addressId.split(":", limit = 3) - if (parts.size > 1 && parts[1].length == 64 && Hex.isHex(parts[1])) { - Address(parts[0].toInt(), parts[1], parts[2]) + if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) { + Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") } else { - Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") - null + if (addressId.startsWith("naddr1")) { + val addr = Nip19Parser.uriToRoute(addressId)?.entity + if (addr is NAddress) { + addr.address() + } else { + Log.w("AddressableId", "Error parsing. naddr1 seems invalid: $addressId") + null + } + } else { + Log.w("AddressableId", "Error parsing. Not a valid address: $addressId") + null + } } } catch (t: Throwable) { Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) null } + } fun isOfKind( addressId: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt index 2d529e766c..ef28d4089b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt @@ -26,9 +26,9 @@ fun Event.mapTaggedAddress(map: (address: String) -> R) = tags.mapTaggedAddr fun Event.firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstIsTaggedAddressableNote(addressableNotes) -fun Event.isTaggedAddressableNote(idHex: String) = tags.isTaggedAddressableNote(idHex) +fun Event.isTaggedAddressableNote(addressId: String) = tags.isTaggedAddressableNote(addressId) -fun Event.isTaggedAddressableNotes(idHexes: Set) = tags.isTaggedAddressableNotes(idHexes) +fun Event.isTaggedAddressableNotes(addressIds: Set) = tags.isTaggedAddressableNotes(addressIds) fun Event.isTaggedAddressableKind(kind: Int) = tags.isTaggedAddressableKind(kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt index 0ef9971c28..3b494e0e93 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray fun TagArrayBuilder.aTag(tag: ATag) = add(tag.toATagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt index 7c1d7b3181..1a793e857e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -34,10 +36,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ETag( override val eventId: HexKey, ) : GenericETag { - override var relay: String? = null + override var relay: NormalizedRelayUrl? = null override var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.author = authorPubKeyHex } @@ -45,16 +47,12 @@ data class ETag( fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + + (relay?.url?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - override fun toTagArray() = toNamedTagArray(TAG_NAME) - - fun toQTagArray() = toNamedTagArray("q") - - fun toNamedTagArray(key: String) = arrayOfNotNull(key, eventId, relay, author) + override fun toTagArray() = assemble(eventId, relay, author) companion object { const val TAG_NAME = "e" @@ -73,7 +71,8 @@ data class ETag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ETag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + return ETag(tag[1], pickRelayHint(tag), pickAuthor(tag)) } @JvmStatic @@ -84,20 +83,51 @@ data class ETag( return tag[1] } + // simple case ["e", "id", "relay"] + // empty tags ["e", "id", "relay", ""] + // current root ["e", "id", "relay", "marker"] + // current root ["e", "id", "relay", "marker", "pubkey"] + // empty tags ["e", "id", "relay", "", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey", "marker"] + // pubkey marker ["e", "id", "pubkey"] // incorrect + // current root ["e", "id", "marker"] // incorrect + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + return null + } + @JvmStatic fun parseAsHint(tag: Array): EventIdHint? { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, author) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt index d5ba844f2e..6b86db0fc1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class EventReference( val eventId: HexKey, val author: HexKey?, - val relayHint: String?, + val relayHint: NormalizedRelayUrl?, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt index 2514b3bcff..4d2fb2426e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface GenericETag { val eventId: HexKey - val relay: String? + val relay: NormalizedRelayUrl? val author: HexKey? fun toTagArray(): Array diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt index 8604076793..d45141c0e8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray fun TagArrayBuilder.eTag(tag: ETag) = add(tag.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt index c82e0e8969..581e65a05c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt @@ -30,6 +30,16 @@ class HashtagTag { @JvmStatic fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged( + tag: Array, + hashtag: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag + + fun isAnyTagged( + tag: Array, + hashtags: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in hashtags + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt index 6d0204a2e5..1b712af7ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.people -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -37,12 +38,12 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class PTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun countMemory(): Long = 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) pubKey.bytesUsedInMemory() + - (relayHint?.bytesUsedInMemory() ?: 0) + (relayHint?.url?.bytesUsedInMemory() ?: 0) fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) @@ -63,17 +64,22 @@ data class PTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return PTag(tag[1], tag.getOrNull(2)) + + val hint = pickRelayHint(tag) + + return PTag(tag[1], hint) + } + + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + return null } @JvmStatic fun parseKey(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].length == 64) { - Log.w("PTag", "Invalid `$TAG_NAME` value ${tag.joinToString(", ")}") - return null - } + ensure(tag[1].length == 64) { return null } return tag[1] } @@ -83,13 +89,18 @@ data class PTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt index 99da4d7742..27750180bf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt @@ -21,8 +21,9 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface PubKeyReferenceTag { val pubKey: HexKey - val relayHint: String? + val relayHint: NormalizedRelayUrl? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt index 023a23980f..d622c41b2b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt @@ -23,10 +23,11 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun TagArrayBuilder.pTag( pubkey: HexKey, - relayHint: String? = null, + relayHint: NormalizedRelayUrl? = null, ) = add(PTag.assemble(pubkey, relayHint)) fun TagArrayBuilder.pTagIds(tag: Set) = addAll(tag.map { PTag.assemble(it, null) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index 92420fa079..edf55518c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -21,20 +21,17 @@ package com.vitorpamplona.quartz.nip02FollowList import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip02FollowList.tags.AddressFollowTag import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -47,13 +44,17 @@ class ContactListEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressHintProvider, PubKeyHintProvider { override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + override fun pubKeyHints() = tags.mapNotNull(ContactTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(ContactTag::parseKey) + /** * Returns a list of p-tags that are verified as hex keys. */ @@ -62,19 +63,30 @@ class ContactListEvent( /** * Returns a list of a-tags that are verified as correct. */ - fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), AddressFollowTag::parseValidAddress) + @Deprecated("Use CommunityListEvent instead.") + fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), ATag::parseValidAddress) fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) + @Deprecated("Use HashtagListEvent instead.") fun unverifiedFollowTagSet() = tags.hashtags() - fun countFollowTags() = tags.countHashtags() - fun follows() = tags.mapNotNull(ContactTag::parseValid) - fun followsTags() = hashtags() + fun relays(): Map? { + val regular = RelaySet.parse(content) - fun relays(): Map? = RelaySet.parse(content) + val normalized = mutableMapOf() + + regular?.forEach { + val key = RelayUrlNormalizer.normalizeOrNull(it.key) + if (key != null) { + normalized.put(key, it.value) + } + } + + return normalized + } companion object { const val KIND = 3 @@ -82,209 +94,61 @@ class ContactListEvent( fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:" - fun createFromScratch( - followUsers: List = emptyList(), - followTags: List = emptyList(), - followGeohashes: List = emptyList(), - followCommunities: List = emptyList(), - relayUse: Map? = emptyMap(), - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): ContactListEvent? { - val content = relayUse?.let { RelaySet.assemble(it) } ?: "" - - val tags = - listOf(AltTag.assemble(ALT)) + - followUsers.map { it.toTagArray() } + - followTags.map { arrayOf("t", it) } + - followCommunities.map { it.toATagArray() } + - followGeohashes.map { arrayOf("g", it) } - - return signer.sign(createdAt, KIND, tags.toTypedArray(), content) - } - - fun createFromScratch( + suspend fun createFromScratch( followUsers: List, - followTags: List, - followGeohashes: List, - followCommunities: List, relayUse: Map?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { + ): ContactListEvent { val content = relayUse?.let { RelaySet.assemble(it) } ?: "" - val tags = - followUsers.map { it.toTagArray() } + - followTags.map { arrayOf("t", it) } + - followCommunities.map { it.toATagArray() } + - followGeohashes.map { arrayOf("g", it) } - + val tags = followUsers.map { it.toTagArray() } return create( content = content, tags = tags.toTypedArray(), signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun followUser( + suspend fun followUser( earlierVersion: ContactListEvent, pubKeyHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedUser(pubKeyHex)) return + ): ContactListEvent { + if (earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion return create( content = earlierVersion.content, tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)), signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun unfollowUser( + suspend fun unfollowUser( earlierVersion: ContactListEvent, pubKeyHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedUser(pubKeyHex)) return + ): ContactListEvent { + if (!earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion return create( content = earlierVersion.content, tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(), signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun followHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray(), - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: ContactListEvent, relayUse: Map?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { + ): ContactListEvent { val content = relayUse?.let { RelaySet.assemble(it) } ?: "" return create( @@ -292,17 +156,15 @@ class ContactListEvent( tags = earlierVersion.tags, signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun create( + suspend fun create( content: String, tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { + ): ContactListEvent { val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { tags @@ -310,7 +172,22 @@ class ContactListEvent( tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) + } + + fun createFromScratch( + followUsers: List = emptyList(), + relayUse: Map? = emptyMap(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ContactListEvent { + val content = relayUse?.let { RelaySet.assemble(it) } ?: "" + + val tags = + listOf(AltTag.assemble(ALT)) + + followUsers.map { it.toTagArray() } + + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt index 1ed15824a5..5910c392c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip02FollowList import android.util.Log import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper data class ReadWrite( val read: Boolean, @@ -31,12 +31,12 @@ data class ReadWrite( class RelaySet { companion object { - fun assemble(relayUse: Map): String = EventMapper.mapper.writeValueAsString(relayUse) + fun assemble(relayUse: Map): String = JsonMapper.mapper.writeValueAsString(relayUse) fun parse(content: String): Map? = try { if (content.isNotEmpty()) { - EventMapper.mapper.readValue>(content) + JsonMapper.mapper.readValue>(content) } else { null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt index b1dbef7e05..b67f628b7b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -36,12 +38,12 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ContactTag( val pubKey: HexKey, ) { - var relayUri: String? = null + var relayUri: NormalizedRelayUrl? = null var petname: String? = null constructor( pubKey: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, petname: String?, ) : this(pubKey) { this.relayUri = relayHint @@ -51,7 +53,7 @@ data class ContactTag( fun countMemory(): Long = 3 * pointerSizeInBytes + pubKey.bytesUsedInMemory() + - (relayUri?.bytesUsedInMemory() ?: 0) + + (relayUri?.url?.bytesUsedInMemory() ?: 0) + (petname?.bytesUsedInMemory() ?: 0) fun toTagArray() = assemble(pubKey, relayUri, petname) @@ -67,7 +69,10 @@ data class ContactTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ContactTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ContactTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -75,8 +80,11 @@ data class ContactTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return try { - ContactTag(decodePublicKey(tag[1]).toHexKey(), tag.getOrNull(2), tag.getOrNull(3)) + ContactTag(decodePublicKey(tag[1]).toHexKey(), hint, tag.getOrNull(3)) } catch (e: Exception) { Log.w("ContactTag", "Can't parse contact list p-tag ${tag.joinToString(", ")}", e) null @@ -110,14 +118,18 @@ data class ContactTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayUri: String? = null, + relayUri: NormalizedRelayUrl? = null, petname: String? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri, petname) + ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri?.url, petname) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt index e9125efcc9..d05879a26a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt @@ -45,6 +45,8 @@ class OtsEvent( EventHintProvider { override fun eventHints() = tags.mapNotNull(TargetEventTag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(TargetEventTag::parseId) + override fun isContentEncoded() = true fun digestEventId() = tags.firstNotNullOfOrNull(TargetEventTag::parseId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolverBuilder.kt similarity index 83% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolverBuilder.kt index b94ee3de89..51c7f616d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolverBuilder.kt @@ -18,15 +18,8 @@ * 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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip03Timestamp -enum class RelayState { - // Websocket connected - CONNECTED, - - // Websocket disconnecting - DISCONNECTING, - - // Websocket disconnected - DISCONNECTED, +interface OtsResolverBuilder { + fun build(): OtsResolver } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt index 4b74a9a181..3e59d83bdb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt @@ -29,15 +29,15 @@ class VerificationStateCache { fun verify( event: OtsEvent, - resolverBuilder: () -> OtsResolver, + resolverBuilder: OtsResolverBuilder, ): VerificationState { cache.put(event.id, VerificationState.Verifying) - return event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } + return event.verifyState(resolverBuilder.build()).also { cache.put(event.id, it) } } fun cacheVerify( event: OtsEvent, - resolverBuilder: () -> OtsResolver, + resolverBuilder: OtsResolverBuilder, ): VerificationState = when (val verif = cache[event.id]) { is VerificationState.Verifying -> verif @@ -45,7 +45,7 @@ class VerificationStateCache { is VerificationState.NetworkError -> { // try again in 5 mins if (verif.time < TimeUtils.fiveMinutesAgo()) { - event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } + event.verifyState(resolverBuilder.build()).also { cache.put(event.id, it) } } else { verify(event, resolverBuilder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt index 413bbdab2a..0ae1903328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt @@ -18,39 +18,38 @@ * 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.datasource +package com.vitorpamplona.quartz.nip04Dm -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -fun filterNip04DMsToAndFromMe( - user: HexKey?, - since: Map?, -): List? { - if (user == null || user.isEmpty()) return null +class PrivateDMCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(10000) { + override fun create(key: PrivateDmEvent): PrivateDMDecryptCache? { + val canDecrypt = key.canDecrypt(signer.pubKey) + return if (key.content.isNotBlank() && canDecrypt) { + PrivateDMDecryptCache(signer) + } else { + null + } + } + } - return listOf( - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - tags = mapOf("p" to listOf(user)), - since = since, - ), - ), - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(user), - since = since, - ), - ), - ) + fun cachedDM(event: PrivateDmEvent): String? = decryptionCache[event]?.cached() + + suspend fun decryptDM(event: PrivateDmEvent) = decryptionCache[event]?.decrypt(event) +} + +class PrivateDMDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: PrivateDmEvent, + signer: NostrSigner, + ) = event.decryptContent(signer) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt index 1b140d8a24..68d68a8051 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt @@ -26,7 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag @@ -37,8 +39,6 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes import kotlinx.collections.immutable.persistentSetOf @Immutable @@ -50,21 +50,35 @@ class PrivateDmEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - ChatroomKeyable { - @Transient private var decryptedContent: Map = mapOf() + ChatroomKeyable, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() }) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) override fun isContentEncoded() = true + fun canDecrypt(signer: NostrSigner) = canDecrypt(signer.pubKey) + + fun canDecrypt(signerPubKey: HexKey) = pubKey == signerPubKey || recipientPubKey() == signerPubKey + + suspend fun decryptContent(signer: NostrSigner): String { + if (!canDecrypt(signer.pubKey)) throw SignerExceptions.UnauthorizedDecryptionException() + + val retVal = signer.decrypt(content, talkingWith(signer.pubKey)) + return if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { + retVal.substring(16) + } else { + retVal + } + } + /** * This may or may not be the actual recipient's pub key. The event is intended to look like a * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for * initial messages. */ - private fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() @@ -79,43 +93,12 @@ class PrivateDmEvent( fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey - override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(persistentSetOf(talkingWith(toRemove))) + override fun chatroomKey(toRemove: HexKey): ChatroomKey = ChatroomKey(persistentSetOf(talkingWith(toRemove))) - /** - * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. - * - * Nip-18 messages should refer to other events by inline references in the content like - * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). - */ fun replyTo() = tags.firstNotNullOfOrNull(MarkedETag::parseId) fun with(pubkeyHex: HexKey): Boolean = pubkeyHex == pubKey || tags.any(PTag::isTagged, pubkeyHex) - fun cachedContentFor(signer: NostrSigner): String? = decryptedContent[signer.pubKey] - - fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - decryptedContent[signer.pubKey]?.let { - onReady(it) - return - } - - signer.decrypt(content, talkingWith(signer.pubKey)) { retVal -> - val content = - if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { - retVal.substring(16) - } else { - retVal - } - - decryptedContent = decryptedContent + Pair(signer.pubKey, content) - - onReady(content) - } - } - companion object { const val KIND = 4 const val ALT = "Private Message" @@ -123,11 +106,11 @@ class PrivateDmEvent( fun prepareMessageToEncrypt( msg: String, - imetas: List? = null, + iMetas: List? = null, advertiseNip18: Boolean = true, ): String { var message = msg - imetas?.forEach { + iMetas?.forEach { message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt index b96439acd4..eb9404e142 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip05DnsIdentifiers -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import kotlinx.coroutines.CancellationException class Nip05 { @@ -46,7 +46,7 @@ class Nip05 { // lowercase version of the username. val nip05url = try { - EventMapper.mapper.readTree(returnBody.lowercase()) + JsonMapper.mapper.readTree(returnBody.lowercase()) } catch (e: Throwable) { if (e is CancellationException) throw e return Result.failure(e) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt index 592949a0a9..96d655716c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt @@ -55,8 +55,12 @@ class DeletionEvent( AddressHintProvider { override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun deleteEvents() = taggedEvents() fun deleteEventIds() = taggedEventIds() @@ -89,6 +93,25 @@ class DeletionEvent( initializer() } + fun buildAddressOnly( + deleteEvents: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) + + deleteEvents.forEach { + if (it is AddressableEvent) { + aTag(it.aTag()) + } + } + + pTagIds(deleteEvents.mapTo(HashSet()) { it.pubKey }) + kinds(deleteEvents.mapTo(HashSet()) { it.kind }) + + initializer() + } + fun buildForVersionOnly( deleteEvents: List, createdAt: Long = TimeUtils.now(), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt new file mode 100644 index 0000000000..8b02fc2bbb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses +import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +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.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +@Immutable +open class BaseNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient + private var citedUsersCache: Set? = null + + @Transient + private var citedNotesCache: Set? = null + + @Transient + private var citedNIP19Cache: List? = null + + fun citedNIP19(): List { + citedNIP19Cache?.let { + return it + } + + return findNostrUris(content).also { citedNIP19Cache = it } + } + + fun citedUsers(): Set { + citedUsersCache?.let { + return it + } + + val citedUsers = mutableSetOf() + + findIndexTagsWithPeople(content, tags, citedUsers) + citedNIP19().forEach { parsed -> + when (parsed) { + is NProfile -> citedUsers.add(parsed.hex) + is NPub -> citedUsers.add(parsed.hex) + } + } + + citedUsersCache = citedUsers + return citedUsers + } + + fun findCitations(): Set { + citedNotesCache?.let { + return it + } + + val citations = mutableSetOf() + + findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() + citedNIP19().forEach { entity -> + when (entity) { + is NEvent -> citations.add(entity.hex) + is NAddress -> citations.add(entity.aTag()) + is NNote -> citations.add(entity.hex) + is NEmbed -> citations.add(entity.event.id) + } + } + + citedNotesCache = citations + return citations + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index f130f910ab..072cf6fbe4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -21,26 +21,10 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers -import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses -import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople -import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag -import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag -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.Note import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -54,16 +38,7 @@ open class BaseThreadedEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig), - EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { - override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) - - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) - - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - +) : BaseNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { fun mentions() = taggedUsers() fun markedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) @@ -89,16 +64,6 @@ open class BaseThreadedEvent( ?: markedRoot()?.eventId ?: unmarkedReply()?.eventId - /* - Not sure if this is needed - open fun replyingToAddress(): ATag? { - val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it[2]) } - val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "reply" }?.let { ATag.parseAtag(it[1], it[2]) } - val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" }?.let { ATag.parseAtag(it[1], it[2]) } - - return newStyleReply ?: newStyleRoot ?: oldStylePositional - }*/ - open fun replyingToAddressOrEvent(): String? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && (it[0] == "e" || it[0] == "a") }?.get(1) val newStyleReply = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "reply" }?.get(1) @@ -107,50 +72,6 @@ open class BaseThreadedEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - @Transient private var citedUsersCache: Set? = null - - @Transient private var citedNotesCache: Set? = null - - fun citedUsers(): Set { - citedUsersCache?.let { - return it - } - - val citedUsers = mutableSetOf() - - findIndexTagsWithPeople(content, tags, citedUsers) - findNostrUris(content).forEach { parsed -> - when (parsed) { - is NProfile -> citedUsers.add(parsed.hex) - is NPub -> citedUsers.add(parsed.hex) - } - } - - citedUsersCache = citedUsers - return citedUsers - } - - fun findCitations(): Set { - citedNotesCache?.let { - return it - } - - val citations = mutableSetOf() - - findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() - findNostrUris(content).forEach { entity -> - when (entity) { - is NEvent -> citations.add(entity.hex) - is NAddress -> citations.add(entity.aTag()) - is Note -> citations.add(entity.hex) - is NEmbed -> citations.add(entity.event.id) - } - } - - citedNotesCache = citations - return citations - } - fun tagsWithoutCitations(): List { val certainRepliesTo = markedReplyTos() val uncertainRepliesTo = unmarkedReplyTos() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index 8ef8ce6bb4..44d6944669 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -23,11 +23,29 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,12 +56,64 @@ class TextNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = "Subject: " + subject() + "\n" + content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + companion object { const val KIND = 1 const val ALT = "A short note: " - fun shortedMessageForAlt(msg: String): String { + private fun shortedMessageForAlt(msg: String): String { if (msg.length < 50) return ALT + msg return ALT + msg.take(50) + "..." } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt index 48cd5458fc..d5b2a85dca 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt @@ -24,43 +24,30 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull -import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class MarkedETag( override val eventId: HexKey, ) : GenericETag { - override var relay: String? = null - var marker: String? = null + override var relay: NormalizedRelayUrl? = null + var marker: MARKER? = null override var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, marker: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.marker = marker this.author = authorPubKeyHex } - constructor(eventId: HexKey, relayHint: String? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { - this.relay = relayHint - this.marker = marker?.code - this.author = authorPubKeyHex - } - - fun countMemory(): Long = - 4 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) - eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + - (marker?.bytesUsedInMemory() ?: 0) + - (author?.bytesUsedInMemory() ?: 0) - fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - override fun toTagArray() = arrayOfNotNull(TAG_NAME, eventId, relay, marker, author) + override fun toTagArray() = assemble(eventId, relay, marker, author) enum class MARKER( val code: String, @@ -69,6 +56,18 @@ data class MarkedETag( REPLY("reply"), MENTION("mention"), FORK("fork"), + ; + + companion object { + fun parse(code: String): MARKER? = + when (code) { + ROOT.code -> ROOT + REPLY.code -> REPLY + MENTION.code -> MENTION + FORK.code -> FORK + else -> null + } + } } companion object { @@ -88,67 +87,71 @@ data class MarkedETag( @JvmStatic fun parse(tag: Array): MarkedETag? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = pickMarker(tag), + authorPubKeyHex = pickAuthor(tag), ) } @JvmStatic fun parseId(tag: Array): HexKey? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } - return tag[ORDER_EVT_ID] + return tag[1] + } + + // simple case ["e", "id", "relay"] + // empty tags ["e", "id", "relay", ""] + // current root ["e", "id", "relay", "marker"] + // current root ["e", "id", "relay", "marker", "pubkey"] + // empty tags ["e", "id", "relay", "", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey", "marker"] + // pubkey marker ["e", "id", "pubkey"] // incorrect + // current root ["e", "id", "marker"] // incorrect + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + if (tag.has(2) && tag[2].length == 64) return tag[2] + return null + } + + @JvmStatic + private fun pickMarker(tag: Array): MARKER? { + if (tag.has(3)) MARKER.parse(tag[3])?.let { return it } + if (tag.has(4)) MARKER.parse(tag[4])?.let { return it } + if (tag.has(2)) MARKER.parse(tag[2])?.let { return it } + return null } @JvmStatic fun parseAllThreadTags(tag: Array): MarkedETag? = if (tag.size >= 2 && tag[0] == TAG_NAME) { - if (tag.size <= 3) { - // simple case ["e", "id", "relay"] - MarkedETag(tag[1], tag.getOrNull(2), null as String?, null) - } else if (tag.size == 4) { - if (tag[3].isEmpty()) { - // empty tags ["e", "id", "relay", ""] - MarkedETag(tag[1], tag[2], null as String?, null) - } else if (tag[3].length == 64) { - // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[3]) - } else if (tag[3] == MARKER.ROOT.code) { - // corrent root ["e", "id", "relay", "root"] - MarkedETag(tag[1], tag[2], tag[3]) - } else if (tag[3] == MARKER.REPLY.code) { - // correct reply ["e", "id", "relay", "reply"] - MarkedETag(tag[1], tag[2], tag[3]) - } else { - // ignore "mention" and "fork" markers - null - } - } else { - // tag.size >= 5 - if (tag[3].isEmpty()) { - // empty tags ["e", "id", "relay", "", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[4]) - } else if (tag[3].length == 64) { - // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[3]) - } else if (tag[3] == MARKER.ROOT.code) { - // corrent root ["e", "id", "relay", "root"] - MarkedETag(tag[1], tag[2], tag[3], tag[4]) - } else if (tag[3] == MARKER.REPLY.code) { - // correct reply ["e", "id", "relay", "reply"] - MarkedETag(tag[1], tag[2], tag[3], tag[4]) - } else { - // ignore "mention" and "fork" markers - null - } - } + MarkedETag( + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = pickMarker(tag), + authorPubKeyHex = pickAuthor(tag), + ) } else { null } @@ -188,22 +191,27 @@ data class MarkedETag( ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val hint = pickRelayHint(tag) + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) } @JvmStatic fun parseRoot(tag: Array): MarkedETag? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null - if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null - // ["e", id hex, relay hint, marker, pubkey] + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val marker = pickMarker(tag) + ensure(marker == MARKER.ROOT) { return null } + return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = marker, + authorPubKeyHex = pickAuthor(tag), ) } @@ -213,23 +221,25 @@ data class MarkedETag( @JvmStatic fun parseUnmarkedRoot(tag: Array): MarkedETag? = if (tag.size in 2..3 && tag[0] == TAG_NAME) { - MarkedETag(tag[1], tag.getOrNull(2), MARKER.ROOT) + MarkedETag(tag[1], pickRelayHint(tag), MARKER.ROOT) } else { null } @JvmStatic fun parseReply(tag: Array): MarkedETag? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null - if (tag[ORDER_MARKER] != MARKER.REPLY.code) return null - // ["e", id hex, relay hint, marker, pubkey] + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val marker = pickMarker(tag) + ensure(marker == MARKER.REPLY) { return null } + return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = marker, + authorPubKeyHex = pickAuthor(tag), ) } @@ -239,25 +249,37 @@ data class MarkedETag( @JvmStatic fun parseUnmarkedReply(tag: Array): MarkedETag? = if (tag.size in 2..3 && tag[0] == TAG_NAME) { - MarkedETag(tag[1], tag.getOrNull(2), MARKER.REPLY) + MarkedETag(tag[1], pickRelayHint(tag), MARKER.REPLY) } else { null } @JvmStatic fun parseRootId(tag: Array): HexKey? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null - if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null - // ["e", id hex, relay hint, marker, pubkey] - return tag[ORDER_EVT_ID] + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val marker = pickMarker(tag) + ensure(marker == MARKER.ROOT) { return null } + + return tag[1] } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, + marker: String?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, marker, author) + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, marker: MARKER?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, marker?.code, author) + ) = assemble(eventId, relay, marker?.code, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt index 2ab00d3448..3f77f66788 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt @@ -54,7 +54,7 @@ fun prepareETagsAsReplyTo( val branchTags = replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { - MarkedETag(it.eventId, it.relay, "", it.author) + MarkedETag(it.eventId, it.relay, null, it.author) } val branch = mutableListOf() @@ -83,7 +83,7 @@ fun prepareMarkedETagsAsReplyTo(replyingTo: EventHintBun val branchTags = replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { - MarkedETag(it.eventId, it.relay, "", it.author) + MarkedETag(it.eventId, it.relay, null, it.author) } val branch = mutableListOf() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 4aded0889f..cccbde429b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -26,29 +26,31 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @Stable class Nip11RelayInformation( - val id: String?, - val name: String?, - val description: String?, - val icon: String?, - val pubkey: String?, - val contact: String?, - val supported_nips: List?, - val supported_nip_extensions: List?, - val software: String?, - val version: String?, - val limitation: RelayInformationLimitation?, - val relay_countries: List?, - val language_tags: List?, - val tags: List?, - val posting_policy: String?, - val payments_url: String?, - val retention: List?, - val fees: RelayInformationFees?, - val nip50: List?, + val id: String? = null, + val name: String? = null, + val description: String? = null, + val icon: String? = null, + val pubkey: String? = null, + val contact: String? = null, + val supported_nips: List? = null, + val supported_nip_extensions: List? = null, + val software: String? = null, + val version: String? = null, + val limitation: RelayInformationLimitation? = null, + val relay_countries: List? = null, + val language_tags: List? = null, + val tags: List? = null, + val posting_policy: String? = null, + val payments_url: String? = null, + val retention: List? = null, + val fees: RelayInformationFees? = null, + val nip50: List? = null, ) { companion object { val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) fun fromJson(json: String): Nip11RelayInformation = mapper.readValue(json, Nip11RelayInformation::class.java) } @@ -56,37 +58,37 @@ class Nip11RelayInformation( @Stable class RelayInformationFee( - val amount: Int?, - val unit: String?, - val period: Int?, - val kinds: List?, + val amount: Int? = null, + val unit: String? = null, + val period: Int? = null, + val kinds: List? = null, ) class RelayInformationFees( - val admission: List?, - val subscription: List?, - val publication: List?, + val admission: List? = null, + val subscription: List? = null, + val publication: List? = null, ) class RelayInformationLimitation( - val max_message_length: Int?, - val max_subscriptions: Int?, - val max_filters: Int?, - val max_limit: Int?, - val max_subid_length: Int?, - val min_prefix: Int?, - val max_event_tags: Int?, - val max_content_length: Int?, - val min_pow_difficulty: Int?, - val auth_required: Boolean?, - val payment_required: Boolean?, - val restricted_writes: Boolean?, - val created_at_lower_limit: Int?, - val created_at_upper_limit: Int?, + val max_message_length: Int? = null, + val max_subscriptions: Int? = null, + val max_filters: Int? = null, + val max_limit: Int? = null, + val max_subid_length: Int? = null, + val min_prefix: Int? = null, + val max_event_tags: Int? = null, + val max_content_length: Int? = null, + val min_pow_difficulty: Int? = null, + val auth_required: Boolean? = null, + val payment_required: Boolean? = null, + val restricted_writes: Boolean? = null, + val created_at_lower_limit: Int? = null, + val created_at_upper_limit: Int? = null, ) class RelayInformationRetentionData( - val kinds: ArrayList?, - val tiem: Int?, - val count: Int?, + val kinds: ArrayList? = null, + val tiem: Int? = null, + val count: Int? = null, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index daeb751421..fa116d6793 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.mapNotNullAsync class NIP17Factory { data class Result( @@ -38,122 +39,82 @@ class NIP17Factory { val wraps: List, ) - private fun recursiveGiftWrapCreation( - event: Event, - remainingTos: List, - signer: NostrSigner, - output: MutableList, - onReady: (List) -> Unit, - ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } - - val next = remainingTos.first() - - SealedRumorEvent.create( - event = event, - encryptTo = next, - signer = signer, - ) { seal -> - GiftWrapEvent.create( - event = seal, - recipientPubKey = next, - ) { giftWrap -> - output.add(giftWrap) - recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady) - } - } - } - - private fun createWraps( + private suspend fun createWraps( event: Event, to: Set, signer: NostrSigner, - onReady: (List) -> Unit, - ) { - val wraps = mutableListOf() - recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) - } + ): List = + mapNotNullAsync( + to.toList(), + ) { next -> + GiftWrapEvent.create( + event = + SealedRumorEvent.create( + event = event, + encryptTo = next, + signer = signer, + ), + recipientPubKey = next, + ) + } - fun createMessageNIP17( + suspend fun createMessageNIP17( template: EventTemplate, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { - signer.sign(template) { senderMessage -> - createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } - } + ): Result { + val senderMessage = signer.sign(template) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer) + return Result( + msg = senderMessage, + wraps = wraps, + ) } - fun createEncryptedFileNIP17( + suspend fun createEncryptedFileNIP17( template: EventTemplate, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { - signer.sign(template) { senderMessage -> - createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } - } + ): Result { + val senderMessage = signer.sign(template) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer) + + return Result( + msg = senderMessage, + wraps = wraps, + ) } - fun createReactionWithinGroup( + suspend fun createReactionWithinGroup( content: String, originalNote: EventHintBundle, to: List, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { + ): Result { val senderPublicKey = signer.pubKey + val template = ReactionEvent.build(content, originalNote) - signer.sign( - ReactionEvent.build(content, originalNote), - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps, - ), - ) - } - } + val senderReaction = signer.sign(template) + val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) + return Result( + msg = senderReaction, + wraps = wraps, + ) } - fun createReactionWithinGroup( + suspend fun createReactionWithinGroup( emojiUrl: EmojiUrlTag, originalNote: EventHintBundle, to: List, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { + ): Result { val senderPublicKey = signer.pubKey + val template = ReactionEvent.build(emojiUrl, originalNote) - signer.sign( - ReactionEvent.build(emojiUrl, originalNote), - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps, - ), - ) - } - } + val senderReaction = signer.sign(template) + val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) + + return Result( + msg = senderReaction, + wraps = wraps, + ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index 81f7ad813c..b47e875ceb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip17Dm.base import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent @@ -42,6 +43,8 @@ open class BaseDMGroupEvent( PubKeyHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + /** Recipients intended to receive this conversation */ fun recipients() = tags.mapNotNull(PTag::parse) @@ -66,6 +69,8 @@ open class BaseDMGroupEvent( return result } + override fun isIncluded(pubKey: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) + override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt index 1889069745..fd45910be9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt @@ -26,4 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Stable data class ChatroomKey( val users: Set, -) +) : Comparable { + override fun compareTo(other: ChatroomKey): Int = users.hashCode().compareTo(other.users.hashCode()) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt index 865ba93cc0..0beca6658c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt @@ -23,5 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm.base import com.vitorpamplona.quartz.nip01Core.core.HexKey interface NIP17Group { + fun isIncluded(pubKey: HexKey): Boolean + fun groupMembers(): Set } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index e4d9c059e7..6293e094c5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent -import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent.Companion.ALT_DESCRIPTION import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -45,7 +44,7 @@ class ChatMessageEvent( companion object { const val KIND = 14 - const val ALT = "Direct message" + const val ALT_DESCRIPTION = "Direct message" fun build( msg: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index c4ba35d46d..8c44924c2c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.quartz.nip17Dm.settings import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,14 +41,7 @@ class ChatMessageRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } + fun relays(): List = tags.mapNotNull(RelayTag::parse) companion object { const val KIND = 10050 @@ -57,54 +52,41 @@ class ChatMessageRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun createTagArray(relays: List): Array> = + fun createTagArray(relays: List): Array> = relays - .map { - arrayOf("relay", it) - }.plusElement(AltTag.assemble("Relay list to receive private messages")) - .toTypedArray() + .map { RelayTag.assemble(it) } + .plusElement( + AltTag.assemble("Relay list to receive private messages"), + ).toTypedArray() - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: ChatMessageRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { + ): ChatMessageRelayListEvent { val tags = earlierVersion.tags - .filter { it[0] != "relay" } + .filter(RelayTag::notMatch) .plus( relays.map { - arrayOf("relay", it) + RelayTag.assemble(it) }, ).toTypedArray() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + return signer.sign(createdAt, KIND, tags, earlierVersion.content) } - fun createFromScratch( - relays: List, + suspend fun create( + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } + ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } - - fun create( - relays: List, + relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") + ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt new file mode 100644 index 0000000000..d1c50cf20a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.settings.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(relay != null && !relay.isLocalHost()) { return null } + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt index 6d76eac17a..acf7e2b1b0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -40,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable class GenericRepostEvent( @@ -55,22 +57,28 @@ class GenericRepostEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) - fun boostedEvents() = tags.mapNotNull(ETag::parse) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun boostedATags() = tags.mapNotNull(ATag::parse) + fun boostedEvent() = tags.lastNotNullOfOrNull(ETag::parse) - fun boostedAddresses() = tags.mapNotNull(ATag::parseAddress) + fun boostedATag() = tags.lastNotNullOfOrNull(ATag::parse) + + fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) + + fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) + + fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId) fun originalAuthors() = tags.mapNotNull(PTag::parse) - fun boostedEventIds() = tags.mapNotNull(ETag::parseId) - - fun boostedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey) fun containedPost() = @@ -86,8 +94,8 @@ class GenericRepostEvent( fun build( boostedPost: Event, - eventSourceRelay: String?, - authorHomeRelay: String?, + eventSourceRelay: NormalizedRelayUrl?, + authorHomeRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { @@ -103,12 +111,11 @@ class GenericRepostEvent( initializer() } - fun create( + suspend fun create( boostedPost: Event, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GenericRepostEvent) -> Unit, - ) { + ): GenericRepostEvent { val content = boostedPost.toJson() val tags = @@ -124,7 +131,7 @@ class GenericRepostEvent( tags.add(arrayOf("k", "${boostedPost.kind}")) tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt index d6056b5103..c7feb946ba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag @@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable class RepostEvent( @@ -53,22 +55,28 @@ class RepostEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) - fun boostedEvents() = tags.mapNotNull(ETag::parse) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun boostedATags() = tags.mapNotNull(ATag::parse) + fun boostedEvent() = tags.lastNotNullOfOrNull(ETag::parse) - fun boostedAddresses() = tags.mapNotNull(ATag::parseAddress) + fun boostedATag() = tags.lastNotNullOfOrNull(ATag::parse) + + fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) + + fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) + + fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId) fun originalAuthors() = tags.mapNotNull(PTag::parse) - fun boostedEventIds() = tags.mapNotNull(ETag::parseId) - - fun boostedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey) fun containedPost() = @@ -84,8 +92,8 @@ class RepostEvent( fun build( boostedPost: Event, - eventSourceRelay: String?, - authorHomeRelay: String?, + eventSourceRelay: NormalizedRelayUrl?, + authorHomeRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt index 4737876e4a..927eb0c872 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag 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.Note +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote -fun Note.toQuoteTag() = QEventTag(hex, null, null) +fun NNote.toQuoteTag() = QEventTag(hex, null, null) fun NEvent.toQuoteTag() = QEventTag(hex, relay.firstOrNull(), author) @@ -39,7 +41,7 @@ fun NEmbed.toQuoteTag() = QEventTag(event.id, null, event.pubKey) } -fun Note.toQuoteTagArray() = QEventTag.assemble(hex, null, null) +fun NNote.toQuoteTagArray() = QEventTag.assemble(hex, null, null) fun NEvent.toQuoteTagArray() = QEventTag.assemble(hex, relay.firstOrNull(), author) @@ -51,3 +53,7 @@ fun NEmbed.toQuoteTagArray() = } else { QEventTag.assemble(event.id, null, event.pubKey) } + +fun ETag.toQTagArray() = QEventTag.assemble(eventId, relay, author) + +fun ATag.toQTagArray() = QAddressableTag.assemble(kind, pubKeyHex, dTag, relay) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt index 77008816d0..b25b6ebfa7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -33,11 +35,11 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class QAddressableTag( val address: Address, ) : QTag { - var relay: String? = null + var relay: NormalizedRelayUrl? = null constructor( address: Address, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(address) { this.relay = relayHint } @@ -46,7 +48,7 @@ data class QAddressableTag( kind: Int, pubKeyHex: HexKey, dTag: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(Address(kind, pubKeyHex, dTag)) { this.relay = relayHint } @@ -54,7 +56,7 @@ data class QAddressableTag( fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) override fun toTagArray() = assemble(address, relay) @@ -67,7 +69,8 @@ data class QAddressableTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } val address = Address.parse(tag[1]) ?: return null - return QAddressableTag(address, tag.getOrNull(2)) + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return QAddressableTag(address, hint) } @JvmStatic @@ -75,13 +78,13 @@ data class QAddressableTag( kind: Int, pubKeyHex: HexKey, dTag: String, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay?.url) @JvmStatic fun assemble( address: Address, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt index ed8a22da13..662c6f9b91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure @@ -32,10 +34,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class QEventTag( val eventId: HexKey, ) : QTag { - var relay: String? = null + var relay: NormalizedRelayUrl? = null var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.author = authorPubKeyHex } @@ -43,7 +45,7 @@ data class QEventTag( fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + + (relay?.url?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) override fun toTagArray() = assemble(eventId, relay, author) @@ -56,14 +58,17 @@ data class QEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return QEventTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, author) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt index 46bdcf6392..14c74e3afe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt @@ -20,9 +20,12 @@ */ package com.vitorpamplona.quartz.nip18Reposts.quotes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.ensure @@ -36,38 +39,77 @@ interface QTag { fun parse(tag: Array): QTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } + + val relayHint = pickRelayHint(tag) + return if (tag[1].length == 64) { - QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + QEventTag(tag[1], relayHint, pickAuthor(tag)) } else { val address = Address.parse(tag[1]) ?: return null - QAddressableTag(address, tag.getOrNull(2)) + QAddressableTag(address, relayHint) } } + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + return null + } + + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + return null + } + @JvmStatic - fun parseKey(tag: Array): String? { + fun parseId(tag: Array): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } return tag[1] } @JvmStatic - fun parseEventAsHint(tag: Array): EventIdHint? { + fun parseEventId(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseEventAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = pickRelayHint(tag) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) + } + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length != 64) { return null } + ensure(!tag[1].contains(':')) { return null } + return tag[1] } @JvmStatic fun parseAddressAsHint(tag: Array): AddressHint? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } ensure(tag[2].isNotEmpty()) { return null } ensure(!tag[1].contains(':')) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = pickRelayHint(tag) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 11a1f3c93c..74aea66ea6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -27,9 +27,9 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.Entity 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.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) @@ -37,7 +37,7 @@ fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it fun TagArrayBuilder.quote(entity: Entity) = when (entity) { - is Note -> add(entity.toQuoteTagArray()) + is NNote -> add(entity.toQuoteTagArray()) is NEvent -> add(entity.toQuoteTagArray()) is NAddress -> add(entity.toQuoteTagArray()) is NEmbed -> add(entity.toQuoteTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt index ffd0bd5f73..84a46fcc79 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt @@ -32,7 +32,7 @@ fun TagArray.mapTaggedQuoteId(map: (eventId: HexKey) -> R) = this.mapValueTa fun TagArray.taggedQuotes() = this.mapNotNull(QTag::parse) -fun TagArray.taggedQuoteIds() = this.mapNotNull(QTag::parseKey) +fun TagArray.taggedQuoteIds() = this.mapNotNull(QTag::parseId) fun TagArray.firstTaggedQuote() = this.firstNotNullOfOrNull(QTag.Companion::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt index c46b594995..dc9bb9d641 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.nip19Bech32 import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.utils.Hex @@ -44,7 +46,10 @@ fun ATag.Companion.parseAtag( try { val parts = atag.split(":", limit = 3) Hex.decode(parts[1]) - ATag(parts[0].toInt(), parts[1], parts[2], relay) + + val relayHint = relay?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + ATag(parts[0].toInt(), parts[1], parts[2], relayHint) } catch (t: Throwable) { Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") null @@ -58,7 +63,7 @@ fun ATag.Companion.parseAtagUnckecked(atag: String): ATag? = null } -fun ATag.toNAddr(overrideRelay: String? = relay): String = NAddress.create(kind, pubKeyHex, dTag, overrideRelay ?: relay) +fun ATag.toNAddr(overrideRelay: NormalizedRelayUrl? = relay): String = NAddress.create(kind, pubKeyHex, dTag, overrideRelay ?: relay) fun ATag.Companion.parseNAddr(naddr: String) = NAddress.parse(naddr)?.let { result -> diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt index 151fa16c43..8d96412d4a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.nip19Bech32 import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -fun Event.toNIP19(relayHint: String? = null): String = +fun Event.toNIP19(relayHint: NormalizedRelayUrl? = null): String = if (this is AddressableEvent) { ATag(kind, pubKey, dTag(), relayHint).toNAddr() } else { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt new file mode 100644 index 0000000000..ee64c350ea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip19Bech32 + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +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.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec + +fun NEvent.toEventHint() = relay.map { EventIdHint(hex, it) } + +fun NAddress.toAddressHint() = relay.map { AddressHint(aTag(), it) } + +fun NProfile.toPubKeyHint() = relay.map { PubKeyHint(hex, it) } + +fun List.eventHints(): List = + mapNotNull { entity -> + if (entity is NEvent) { + entity.toEventHint() + } else { + null + } + }.flatten() + +fun List.eventIds(): List = + mapNotNull { entity -> + when (entity) { + is NEvent -> entity.hex + is NNote -> entity.hex + is NEmbed -> entity.event.id + else -> null + } + } + +fun List.addressHints(): List = + mapNotNull { entity -> + if (entity is NAddress) { + entity.toAddressHint() + } else { + null + } + }.flatten() + +fun List.addressIds(): List = + mapNotNull { entity -> + when (entity) { + is NAddress -> entity.aTag() + is NEmbed -> if (entity.event is AddressableEvent) entity.event.addressTag() else null + else -> null + } + } + +fun List.pubKeyHints(): List = + mapNotNull { entity -> + if (entity is NProfile) { + entity.relay.map { PubKeyHint(entity.hex, it) } + } else { + null + } + }.flatten() + +fun List.pubKeys(): List = + mapNotNull { entity -> + when (entity) { + is NProfile -> entity.hex + is NPub -> entity.hex + is NSec -> entity.hex + else -> null + } + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index 140706a7cf..a598bb1d27 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -31,11 +31,11 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.Entity 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.NNote 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.utils.Hex import kotlinx.coroutines.CancellationException import java.util.regex.Pattern @@ -43,13 +43,13 @@ import java.util.regex.Pattern object Nip19Parser { private val nip19PlusNip46regex: Pattern = Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1|ncryptsec1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + "(nostr:)?@?((nsec1|npub1|note1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})|(nevent1|naddr1|nprofile1|nrelay1|nembed1|ncryptsec1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+))([\\S]*)", Pattern.CASE_INSENSITIVE, ) val nip19regex: Pattern = Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + "(nostr:)?@?((nsec1|npub1|note1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})|(nevent1|naddr1|nprofile1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+))([\\S]*)", Pattern.CASE_INSENSITIVE, ) @@ -75,8 +75,8 @@ object Nip19Parser { return null } - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 + val type = matcher.group(3) ?: matcher.group(5) // npub1 + val key = matcher.group(4) ?: matcher.group(6) // bech32 return type!! + key } catch (e: Throwable) { @@ -95,9 +95,9 @@ object Nip19Parser { return null } - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) // additional chars + val type = matcher.group(3) ?: matcher.group(5) // npub1 + val key = matcher.group(4) ?: matcher.group(6) // bech32 + val additionalChars = matcher.group(7) // additional chars if (type == null) return null @@ -121,7 +121,7 @@ object Nip19Parser { when (type.lowercase()) { "nsec1" -> NSec.parse(bytes) "npub1" -> NPub.parse(bytes) - "note1" -> Note.parse(bytes) + "note1" -> NNote.parse(bytes) "nprofile1" -> NProfile.parse(bytes) "nevent1" -> NEvent.parse(bytes) "nrelay1" -> NRelay.parse(bytes) @@ -136,11 +136,27 @@ object Nip19Parser { null } - fun parseAll( - content: String, - regex: Pattern, - ): List { - val matcher2 = regex.matcher(content) + fun parseAll(content: String): List { + val matcher = nip19regex.matcher(content) + val returningList = mutableListOf() + while (matcher.find()) { + val type = matcher.group(3) ?: matcher.group(5) // npub1 + val key = matcher.group(4) ?: matcher.group(6) // bech32 + val additionalChars = matcher.group(7) // additional chars + + if (type != null) { + val parsed = parseComponents(type, key, additionalChars)?.entity + + if (parsed != null) { + returningList.add(parsed) + } + } + } + return returningList + } + + fun parseAllEvents(content: String): List { + val matcher2 = nip19regexEvents.matcher(content) val returningList = mutableListOf() while (matcher2.find()) { val type = matcher2.group(2) // npub1 @@ -157,10 +173,6 @@ object Nip19Parser { } return returningList } - - fun parseAll(content: String): List = parseAll(content, nip19regex) - - fun parseAllEvents(content: String): List = parseAll(content, nip19regexEvents) } fun decodePublicKey(key: String): ByteArray = @@ -177,7 +189,7 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? = is NSec -> parsed.hex is NPub -> null is NProfile -> null - is Note -> null + is NNote -> null is NEvent -> null is NEmbed -> null is NRelay -> null @@ -195,7 +207,7 @@ fun decodePublicKeyAsHexOrNull(key: String): HexKey? = is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() is NPub -> parsed.hex is NProfile -> parsed.hex - is Note -> null + is NNote -> null is NEvent -> null is NEmbed -> null is NRelay -> null @@ -213,7 +225,7 @@ fun decodeEventIdAsHexOrNull(key: String): HexKey? = is NSec -> null is NPub -> null is NProfile -> null - is Note -> parsed.hex + is NNote -> parsed.hex is NEvent -> parsed.hex is NAddress -> parsed.aTag() is NEmbed -> null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt index 8deadc46c3..cf9525037d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt @@ -42,6 +42,11 @@ fun TlvBuilder.addStringIfNotNull( data: String?, ) = addStringIfNotNull(type.id, data) +fun TlvBuilder.addStringIfNotBlank( + type: TlvTypes, + data: String, +) = addStringIfNotBlank(type.id, data) + fun TlvBuilder.addHexIfNotNull( type: TlvTypes, data: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt index 52955108ca..3fe4138749 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addInt import addString -import addStringIfNotNull +import addStringIfNotBlank import android.util.Log import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -38,10 +40,12 @@ data class NAddress( val kind: Int, val author: String, val dTag: String, - val relay: List, + val relay: List, ) : Entity { fun aTag(): String = Address.assemble(kind, author, dTag) + fun address() = Address(kind, author, dTag) + companion object { fun parse(naddr: String): NAddress? { try { @@ -52,7 +56,6 @@ data class NAddress( } } catch (e: Throwable) { Log.w("NAddress", "Issue trying to Decode NIP19 $this: ${e.message}") - // e.printStackTrace() } return null @@ -68,20 +71,37 @@ data class NAddress( val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null - return NAddress(kind, author, d, relay) + return NAddress(kind, author, d, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } fun create( kind: Int, pubKeyHex: String, dTag: String, - vararg relays: String?, + relay: NormalizedRelayUrl?, + ): String = + TlvBuilder() + .apply { + addString(TlvTypes.SPECIAL, dTag) + if (relay != null) { + addStringIfNotBlank(TlvTypes.RELAY, relay.url) + } + addHex(TlvTypes.AUTHOR, pubKeyHex) + addInt(TlvTypes.KIND, kind) + }.build() + .toNAddress() + + fun create( + kind: Int, + pubKeyHex: String, + dTag: String, + relays: List, ): String = TlvBuilder() .apply { addString(TlvTypes.SPECIAL, dTag) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotBlank(TlvTypes.RELAY, it.url) } addHex(TlvTypes.AUTHOR, pubKeyHex) addInt(TlvTypes.KIND, kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt index b5d4a028ae..00ebc15943 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt @@ -23,9 +23,7 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip19Bech32.toNEmbed -import java.io.ByteArrayOutputStream -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream +import com.vitorpamplona.quartz.utils.GZip @Immutable data class NEmbed( @@ -34,18 +32,9 @@ data class NEmbed( companion object { fun parse(bytes: ByteArray): NEmbed? { if (bytes.isEmpty()) return null - return NEmbed(Event.fromJson(ungzip(bytes))) + return NEmbed(Event.fromJson(GZip.decompress(bytes))) } - fun create(event: Event): String = gzip(event.toJson()).toNEmbed() - - fun gzip(content: String): ByteArray { - val bos = ByteArrayOutputStream() - GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(content) } - val array = bos.toByteArray() - return array - } - - fun ungzip(content: ByteArray): String = GZIPInputStream(content.inputStream()).bufferedReader(Charsets.UTF_8).use { it.readText() } + fun create(event: Event): String = GZip.compress(event.toJson()).toNEmbed() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt index 62114d1d82..308220c8f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt @@ -25,6 +25,8 @@ import addHexIfNotNull import addIntIfNotNull import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.asStringList import com.vitorpamplona.quartz.nip19Bech32.firstAsHex @@ -35,7 +37,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNEvent @Immutable data class NEvent( val hex: String, - val relay: List, + val relay: List, val author: String?, val kind: Int?, ) : Entity { @@ -52,20 +54,42 @@ data class NEvent( if (hex.isBlank()) return null - return NEvent(hex, relay, author, kind) + return NEvent( + hex, + relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }, + author, + kind, + ) } fun create( idHex: String, author: String?, kind: Int?, - vararg relays: String?, + relay: NormalizedRelayUrl?, + ): String = + TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, idHex) + if (relay != null) { + addStringIfNotNull(TlvTypes.RELAY, relay.url) + } + addHexIfNotNull(TlvTypes.AUTHOR, author) + addIntIfNotNull(TlvTypes.KIND, kind) + }.build() + .toNEvent() + + fun create( + idHex: String, + author: String?, + kind: Int?, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, idHex) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotNull(TlvTypes.RELAY, it.url) } addHexIfNotNull(TlvTypes.AUTHOR, author) addIntIfNotNull(TlvTypes.KIND, kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt similarity index 93% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt index 6ac4c7c143..11bca830ed 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt @@ -27,13 +27,13 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.toNote @Immutable -data class Note( +data class NNote( val hex: String, ) : Entity { companion object { - fun parse(bytes: ByteArray): Note? { + fun parse(bytes: ByteArray): NNote? { if (bytes.isEmpty()) return null - return Note(bytes.toHexKey()) + return NNote(bytes.toHexKey()) } fun create(eventId: HexKey): String = eventId.hexToByteArray().toNote() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index 4c84539956..55c84758f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.asStringList import com.vitorpamplona.quartz.nip19Bech32.firstAsHex @@ -33,7 +35,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNProfile @Immutable data class NProfile( val hex: String, - val relay: List, + val relay: List, ) : Entity { companion object { fun parse(bytes: ByteArray): NProfile? { @@ -46,18 +48,18 @@ data class NProfile( if (hex.isBlank()) return null - return NProfile(hex, relay) + return NProfile(hex, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } fun create( authorPubKeyHex: String, - relays: List, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, authorPubKeyHex) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotNull(TlvTypes.RELAY, it.url) } }.build() .toNProfile() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt index c422393108..6f0bf29546 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt @@ -21,12 +21,16 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 @Immutable data class NSec( val hex: String, ) : Entity { + fun toPubKeyHex() = Nip01.pubKeyCreate(hex.hexToByteArray()).toHexKey() + companion object { fun parse(bytes: ByteArray): NSec? { if (bytes.isEmpty()) return null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt index a515d26768..b09881d57b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt @@ -55,6 +55,15 @@ class TlvBuilder { data: String?, ) = data?.let { addString(type, it) } + fun addStringIfNotBlank( + type: Byte, + data: String, + ) { + if (data.isNotBlank()) { + addString(type, data) + } + } + fun addHexIfNotNull( type: Byte, data: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt index 54d7eb2631..28cca61d37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt @@ -22,8 +22,9 @@ package com.vitorpamplona.quartz.nip21UriScheme import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.toNIP19 -fun Event.toNostrUri(relayHint: String? = null): String = "nostr:${toNIP19(relayHint)}" +fun Event.toNostrUri(relayHint: NormalizedRelayUrl? = null): String = "nostr:${toNIP19(relayHint)}" fun EventHintBundle.toNostrUri(): String = "nostr:${toNEvent()}" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 0aaf57f239..b68eb4a34f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -29,9 +29,19 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAddressTag import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAuthorTag @@ -44,6 +54,7 @@ import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.utils.TimeUtils @@ -61,12 +72,59 @@ class CommentEvent( RootScope, EventHintProvider, PubKeyHintProvider, - AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(RootAuthorTag::parseAsHint) + tags.mapNotNull(ReplyAuthorTag::parseAsHint) + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = content - override fun eventHints() = tags.mapNotNull(RootEventTag::parseAsHint) + tags.mapNotNull(ReplyEventTag::parseAsHint) + override fun pubKeyHints(): List { + val pHints = + tags.mapNotNull(RootAuthorTag::parseAsHint) + + tags.mapNotNull(ReplyAuthorTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() - override fun addressHints() = tags.mapNotNull(RootAddressTag::parseAsHint) + tags.mapNotNull(ReplyAddressTag::parseAsHint) + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = + tags.mapNotNull(RootAuthorTag::parseKey) + + tags.mapNotNull(ReplyAuthorTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + + override fun eventHints(): List { + val eHints = tags.mapNotNull(RootEventTag::parseAsHint) + tags.mapNotNull(ReplyEventTag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(RootEventTag::parseKey) + tags.mapNotNull(ReplyEventTag::parseKey) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(RootAddressTag::parseAsHint) + tags.mapNotNull(ReplyAddressTag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(RootAddressTag::parseAddressId) + tags.mapNotNull(ReplyAddressTag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } fun rootAuthor() = tags.firstNotNullOfOrNull(RootAuthorTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt index ddc263e61e..d871c54890 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip22Comments import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags @@ -39,12 +40,12 @@ import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId fun TagArrayBuilder.rootAddress( addressId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = addUnique(RootAddressTag.assemble(addressId, relayHint)) fun TagArrayBuilder.rootEvent( eventId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, pubkey: String?, ) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) @@ -60,17 +61,17 @@ fun TagArrayBuilder.rootKind(id: ExternalId) = addUnique(RootKindT fun TagArrayBuilder.rootAuthor( pubKey: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, ) = add(RootAuthorTag.assemble(pubKey, relay)) fun TagArrayBuilder.replyAddress( addressId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = addUnique(ReplyAddressTag.assemble(addressId, relayHint)) fun TagArrayBuilder.replyEvent( eventId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, pubkey: String?, ) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) @@ -86,7 +87,7 @@ fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(ReplyKin fun TagArrayBuilder.replyAuthor( pubKey: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, ) = add(ReplyAuthorTag.assemble(pubKey, relay)) fun TagArrayBuilder.notify(list: List) = pTags(list) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt index 28d4a18275..4582ce1733 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class ReplyAddressTag( val addressId: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { fun toTagArray() = assemble(addressId, relay) @@ -47,7 +49,10 @@ class ReplyAddressTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyAddressTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAddressTag(tag[1], relayHint) } @JvmStatic @@ -64,21 +69,25 @@ class ReplyAddressTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( addressId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt index 8d7c3749f5..f006a731e1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ReplyAuthorTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -47,7 +49,10 @@ data class ReplyAuthorTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], hint) } @JvmStatic @@ -64,13 +69,17 @@ data class ReplyAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt index 6d4e42de16..4f891256f2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -34,7 +36,7 @@ import com.vitorpamplona.quartz.utils.ensure class ReplyEventTag( val ref: EventReference, ) { - constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) fun toTagArray() = assemble(ref) @@ -61,7 +63,7 @@ class ReplyEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3)) } @JvmStatic @@ -87,15 +89,19 @@ class ReplyEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, pubkey: String?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) @JvmStatic fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt index 2c06d9faa6..a3c9a23edf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt @@ -59,7 +59,7 @@ class ReplyIdentifierTag { fun matchOrNull( tag: Array, encodedScope: Set, - ) = if (tag.has(1) && tag[0] == RootIdentifierTag.Companion.TAG_NAME && tag[1] in encodedScope) { + ) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) { tag[1] } else { null @@ -93,7 +93,7 @@ class ReplyIdentifierTag { @JvmStatic fun parseExternalId(tag: Tag): ExternalId? { ensure(tag.has(1)) { return null } - ensure(tag[0] == RootIdentifierTag.Companion.TAG_NAME) { return null } + ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val value = tag[1] diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt index 17cd967317..9278c3e0cd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class RootAddressTag( val addressId: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { fun toTagArray() = assemble(addressId, relay) @@ -59,7 +61,10 @@ class RootAddressTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return RootAddressTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RootAddressTag(tag[1], relayHint) } @JvmStatic @@ -84,21 +89,25 @@ class RootAddressTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( addressId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt index 469600c52d..a4afdeda8b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.PUBKEY_LENGTH import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -33,7 +35,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class RootAuthorTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -46,7 +48,10 @@ data class RootAuthorTag( @JvmStatic fun parse(tag: Tag): ReplyAuthorTag? { if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].length != PUBKEY_LENGTH) return null - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], relayHint) } @JvmStatic @@ -54,7 +59,10 @@ data class RootAuthorTag( ensure(tag.size >= 2) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == PUBKEY_LENGTH) { return null } - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], relayHint) } @JvmStatic @@ -69,13 +77,17 @@ data class RootAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return PubKeyHint(tag[1], relayHint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt index 3ca2355cce..ce210e6e8a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -34,7 +36,7 @@ import com.vitorpamplona.quartz.utils.ensure class RootEventTag( val ref: EventReference, ) { - constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) fun toTagArray() = assemble(ref) @@ -61,7 +63,10 @@ class RootEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return RootEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RootEventTag(tag[1], relayHint, tag.getOrNull(3)) } @JvmStatic @@ -87,15 +92,19 @@ class RootEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, pubkey: String?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) @JvmStatic fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 01f2270f8c..23b131b0bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -35,12 +39,19 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -57,16 +68,55 @@ class LongTextNoteEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider, - RootScope { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + RootScope, + SearchableEvent { + override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content - override fun eventHints() = tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun addressHints() = tags.mapNotNull(QTag::parseAddressAsHint) + return qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return qHints + nip19Hints + } + + override fun addressHints(): List { + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index 5fc3948b10..3c11b0d017 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -54,10 +54,16 @@ class ReactionEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun originalPost() = tags.mapNotNull(ETag::parseId) fun originalAuthor() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index aa6a78b8be..c9dac836ce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -20,14 +20,19 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.admin +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,9 +46,33 @@ class ChannelCreateEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider { + @Transient + var cache: ChannelDataNorm? = null + override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() - fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + override fun linkedEventIds() = listOf(id) + + fun isEncrypted() = tags.any { it.has(1) && it[0] == "encrypted" && it[1] == "true" } + + fun channelInfo(): ChannelDataNorm { + cache?.let { return it } + + val newInfo = + try { + if (isEncrypted()) { + ChannelDataNorm() + } else { + ChannelData.parse(content)?.normalize() ?: ChannelDataNorm() + } + } catch (e: JsonParseException) { + Log.w("ChannelCreateEvent", "Failure to parse ${this.toJson()}", e) + ChannelDataNorm() + } + + cache = newInfo + return newInfo + } companion object { const val KIND = 40 @@ -52,13 +81,13 @@ class ChannelCreateEvent( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), createdAt, initializer) fun build( - data: ChannelData, + data: ChannelDataNorm, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, data.toContent(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt index eaa611d323..66b579577d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt @@ -46,6 +46,8 @@ class ChannelHideMessageEvent( EventHintProvider { override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + fun eventsToHide() = tags.taggedEventIds() companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index 13f7db101f..526214f20b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -20,16 +20,21 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.admin +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.base.BasePublicChatEvent import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -44,9 +49,36 @@ class ChannelMetadataEvent( sig: HexKey, ) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider { - override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() + @Transient + var cache: ChannelDataNorm? = null - fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + override fun eventHints() = + channelInfo().relays?.mapNotNull { relay -> + channelId()?.let { EventIdHint(it, relay) } + } ?: emptyList() + + override fun linkedEventIds() = channelId()?.let { listOf(it) } ?: emptyList() + + fun isEncrypted() = tags.any { it.has(1) && it[0] == "encrypted" && it[1] == "true" } + + fun channelInfo(): ChannelDataNorm { + cache?.let { return it } + + val newInfo = + try { + if (isEncrypted()) { + ChannelDataNorm() + } else { + ChannelData.parse(content)?.normalize() ?: ChannelDataNorm() + } + } catch (e: JsonParseException) { + Log.w("ChannelCreateEvent", "Failure to parse ${this.toJson()}", e) + ChannelDataNorm() + } + + cache = newInfo + return newInfo + } companion object { const val KIND = 41 @@ -56,24 +88,24 @@ class ChannelMetadataEvent( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, channel: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), channel, createdAt, initializer) fun build( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, channel: ETag, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), channel, createdAt, initializer) fun build( - data: ChannelData, + data: ChannelDataNorm, channel: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -84,7 +116,7 @@ class ChannelMetadataEvent( } fun build( - data: ChannelData, + data: ChannelDataNorm, channel: ETag, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt index 634e9e8473..3d3b964034 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt @@ -46,6 +46,8 @@ class ChannelMuteUserEvent( PubKeyHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun usersToMute() = tags.taggedUserIds() companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt index 088fed6f66..b93684d5cc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.base -import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Immutable data class ChannelData( @@ -34,15 +35,22 @@ data class ChannelData( ) { fun toContent() = assemble(this) - companion object { - fun parse(content: String): ChannelData? = - try { - EventMapper.mapper.readValue(content) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null, null) - } + fun normalize() = ChannelDataNorm(name, about, picture, relays?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) - fun assemble(data: ChannelData) = EventMapper.mapper.writeValueAsString(data) + companion object { + fun parse(content: String): ChannelData? = JsonMapper.mapper.readValue(content) + + fun assemble(data: ChannelData) = JsonMapper.mapper.writeValueAsString(data) } } + +data class ChannelDataNorm( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val relays: List? = null, +) { + fun denormalize() = ChannelData(name, about, picture, relays?.mapNotNull { it.url }) + + fun toContent() = denormalize().toContent() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt index 727ee1a0ce..49e72ed80d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt @@ -22,16 +22,23 @@ package com.vitorpamplona.quartz.nip28PublicChat.list import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.nip51Lists.removeParsing import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -42,24 +49,11 @@ class ChannelListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: Set? = null +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(ChannelTag::parseAsHint) - fun publicAndPrivateChannels( - signer: NostrSigner, - onReady: (Set) -> Unit, - ) { - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - return - } - - mergeTagList(signer) { - val set = it.mapNotNull(ETag::parseAsHint).toSet() - publicAndPrivateEventCache = set - onReady(set) - } - } + override fun linkedEventIds() = tags.mapNotNull(ChannelTag::parseId) companion object { const val KIND = 10005 @@ -68,170 +62,157 @@ class ChannelListEvent( fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) - private fun createChannelBase( - tags: Array>, + suspend fun create( + channel: ChannelTag, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.create( - tags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun createChannel( - channel: EventHintBundle, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = createChannelBase( - tags = arrayOf(ETag.assemble(channel.event.id, channel.relay, channel.event.pubKey)), + ) = create( + channels = listOf(channel), isPrivate = isPrivate, signer = signer, createdAt = createdAt, - onReady = onReady, ) - fun createChannel( - channel: EventIdHint, + suspend fun create( + channels: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = createChannelBase( - tags = arrayOf(ETag.assemble(channel.eventId, channel.relay, null)), + ): ChannelListEvent = + if (isPrivate) { + create( + publicChannels = emptyList(), + privateChannels = channels, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicChannels = channels, + privateChannels = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: ChannelListEvent, + channel: ChannelTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = add( + earlierVersion = earlierVersion, + channels = listOf(channel), isPrivate = isPrivate, signer = signer, createdAt = createdAt, - onReady = onReady, ) - fun createChannels( - channels: List, + suspend fun add( + earlierVersion: ChannelListEvent, + channels: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = createChannelBase( - tags = channels.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray(), - isPrivate = isPrivate, + ): ChannelListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(channels.map { it.toTagIdOnly() }) + channels.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(channels.map { it.toTagIdOnly() }) + channels.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: ChannelListEvent, + channel: ChannelTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.removeParsing(ChannelTag::parseId, channel.eventId), + tags = earlierVersion.tags.removeParsing(ChannelTag::parseId, channel.eventId), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, signer = signer, createdAt = createdAt, - onReady = onReady, ) - fun removeChannel( - earlierVersion: ChannelListEvent, - channel: HexKey, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.removeAll( - earlierVersion, - ETag.assemble(channel, null, null), - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - private fun addChannelBase( - earlierVersion: ChannelListEvent, - newTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.addAll( - earlierVersion, - newTags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun addChannel( - earlierVersion: ChannelListEvent, - channel: EventHintBundle, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = addChannelBase( - earlierVersion, - arrayOf(ETag.assemble(channel.event.id, channel.relay, channel.event.pubKey)), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addChannel( - earlierVersion: ChannelListEvent, - channel: EventIdHint, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = addChannelBase( - earlierVersion, - arrayOf(ETag.assemble(channel.eventId, channel.relay, null)), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addChannels( - earlierVersion: ChannelListEvent, - channels: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = addChannelBase( - earlierVersion, - channels.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray(), - isPrivate, - signer, - createdAt, - onReady, - ) - - private fun create( + suspend fun resign( content: String, - tags: Array>, + tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { + ): ChannelListEvent { val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { + if (tags.fastAny(AltTag::match)) { tags } else { - tags + AltTag.Companion.assemble(ALT) + tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicChannels: List = emptyList(), + privateChannels: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent { + val template = build(publicChannels, privateChannels, signer, createdAt) + return signer.sign(template) } fun create( - list: List, + publicChannels: List = emptyList(), + privateChannels: List = emptyList(), signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent? { - val tags = list.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray() - return signer.sign(createdAt, KIND, tags, "") + ): ChannelListEvent { + val privateTagArray = privateChannels.map { it.toTagArray() }.toTypedArray() + val publicTagArray = publicChannels.map { it.toTagArray() }.toTypedArray() + AltTag.assemble(ALT) + return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) + } + + suspend fun build( + publicChannels: List = emptyList(), + privateChannels: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateChannels.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + channels(publicChannels) + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt index b297209a59..ef99d88271 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt @@ -22,9 +22,13 @@ package com.vitorpamplona.quartz.nip28PublicChat.list import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag fun TagArrayBuilder.followChat( eventId: HexKey, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ) = addUnique(ETag.assemble(eventId, relayUrl, null)) + +fun TagArrayBuilder.channels(rooms: List) = addAll(rooms.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayExt.kt new file mode 100644 index 0000000000..b0b929173e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.list + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag + +fun TagArray.channels() = mapNotNull(ChannelTag::parse) + +fun TagArray.channelSet() = mapNotNullTo(mutableSetOf(), ChannelTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/tags/ChannelTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/tags/ChannelTag.kt new file mode 100644 index 0000000000..88aafdff84 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/tags/ChannelTag.kt @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.list.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +class ChannelTag( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, + val author: HexKey? = null, +) { + fun countMemory(): Long = + 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.url?.bytesUsedInMemory() ?: 0) + + (author?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + fun toTagArray() = assemble(eventId, relay, author) + + fun toTagIdOnly() = assemble(eventId, null, null) + + companion object { + const val TAG_NAME = "e" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + @JvmStatic + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun parse(tag: Array): ChannelTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + return ChannelTag(tag[1], pickRelayHint(tag), pickAuthor(tag)) + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + return null + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt index 37ff66f2b4..97a0440ba6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt @@ -24,16 +24,33 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.markedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip28PublicChat.base.reply import com.vitorpamplona.quartz.nip37Drafts.ExposeInDraft +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -46,7 +63,59 @@ class ChannelMessageEvent( sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel, - ExposeInDraft { + ExposeInDraft, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + override fun channel() = markedRoot() ?: unmarkedRoot() override fun channelId() = channel()?.eventId diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index 4fdb1bbc7e..4e7f4d0459 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.repository.name -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -40,7 +40,7 @@ class EmojiPackEvent( tags: Array>, content: String, sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30030 const val ALT_DESCRIPTION = "Emoji pack" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt index a4c62ff182..e4073aa414 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt @@ -46,6 +46,8 @@ class EmojiPackSelectionEvent( AddressHintProvider { override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun emojiPacks() = tags.mapNotNull(ATag::parseAddress) fun emojiPackIds() = tags.mapNotNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt index 66b44623e7..910c5a1793 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt @@ -27,6 +27,9 @@ class AltTag { companion object { const val TAG_NAME = "alt" + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt index 806685606b..3e99eea631 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt @@ -27,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -34,8 +37,15 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip14Subject.SubjectTag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -49,12 +59,53 @@ class GitIssueEvent( ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, EventHintProvider, - AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = "Subject: " + subject() + "\n" + content - override fun eventHints() = tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + return qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } fun repositoryHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt index 243ec363bd..ab12e8be3d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt @@ -48,10 +48,16 @@ class GitPatchEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(MarkedETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + private fun innerRepository() = tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } @@ -101,12 +107,11 @@ class GitPatchEvent( const val KIND = 1617 const val ALT = "A Git Patch" - fun create( + suspend fun create( patch: String, createdAt: Long = TimeUtils.now(), signer: NostrSigner, - onReady: (GitPatchEvent) -> Unit, - ) { + ): GitPatchEvent { val content = patch val tags = mutableListOf( @@ -115,7 +120,7 @@ class GitPatchEvent( tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt index 9e7ccb4092..f6483a6730 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt @@ -27,14 +27,24 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.prepareMarkedETagsAsReplyTo import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,11 +63,51 @@ class GitReplyEvent( PubKeyHintProvider, EventHintProvider, AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun eventHints(): List { + val eHints = tags.mapNotNull(ETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + return eHints + qHints + nip19Hints + } - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(ETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } fun repositoryHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index 7cc031fb53..6b761c5142 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent.Companion.ALT_DESCRIPTION import com.vitorpamplona.quartz.nip34Git.repository.tags.CloneTag import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag @@ -54,7 +53,7 @@ class GitRepositoryEvent( companion object { const val KIND = 30617 - const val ALT = "Git Repository" + const val ALT_DESCRIPTION = "Git Repository" fun build( name: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt index 99b8fc2513..483a49a8d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt @@ -27,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -37,6 +40,12 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,11 +62,49 @@ class TorrentCommentEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + return eHints + qHints + nip19Hints + } - override fun addressHints() = tags.mapNotNull(QTag::parseAddressAsHint) + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } fun torrent() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) ?: tags.firstNotNullOfOrNull(ETag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt index 5e451e384f..47c1a15c6e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt @@ -20,26 +20,30 @@ */ package com.vitorpamplona.quartz.nip36SensitiveContent -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull class ContentWarningTag( - val reason: String, + val reason: String? = null, ) { - fun countMemory(): Long = 1 * pointerSizeInBytes + reason.bytesUsedInMemory() - fun toTagArray() = assemble(reason) companion object { const val TAG_NAME = "content-warning" + @JvmStatic + fun isTag(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + @JvmStatic fun parse(tags: Array): ContentWarningTag { require(tags[0] == TAG_NAME) - return ContentWarningTag(tags[1]) + return ContentWarningTag(tags.getOrNull(1)) } @JvmStatic - fun assemble(reason: String) = arrayOf(TAG_NAME, reason) + fun assemble() = arrayOfNotNull(TAG_NAME) + + @JvmStatic + fun assemble(reason: String?) = arrayOfNotNull(TAG_NAME, reason) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt index 3698637340..fa44f876b7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt @@ -23,4 +23,6 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +fun TagArrayBuilder.contentWarning() = add(ContentWarningTag.assemble()) + fun TagArrayBuilder.contentWarning(reason: String) = add(ContentWarningTag.assemble(reason)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt index f9b1be1a63..e087a6aa89 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt @@ -21,11 +21,10 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -fun TagArray.isSensitive() = this.any { (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) } +val nsfwTags = setOf("nsfw", "nude", "NSFW", "NUDE", "Nsfw", "Nude") -fun TagArray.isSensitiveOrNSFW() = - this.any { - (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) || - (it.size > 1 && it[0] == "t" && (it[1].equals("nsfw", true) || it[1].equals("nude", true))) - } +fun TagArray.isSensitive() = this.any(ContentWarningTag::isTag) + +fun TagArray.isSensitiveOrNSFW() = this.any { ContentWarningTag.isTag(it) || HashtagTag.isAnyTagged(it, nsfwTags) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt index 15e9a15c8f..9a6befef48 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt @@ -30,30 +30,25 @@ import com.vitorpamplona.quartz.utils.TimeUtils class DraftBuilder { companion object { - fun encryptAndSign( + suspend fun encryptAndSign( dTag: String, draft: T, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { - signer.nip44Encrypt(draft.toJson(), signer.pubKey) { encryptedContent -> - val template = - eventTemplate(DraftEvent.KIND, encryptedContent, createdAt) { - alt(DraftEvent.ALT_DESCRIPTION) - dTag(dTag) - kind(draft.kind) + ): DraftEvent { + val encryptedContent = signer.nip44Encrypt(draft.toJson(), signer.pubKey) + val template = + eventTemplate(DraftEvent.KIND, encryptedContent, createdAt) { + alt(DraftEvent.ALT_DESCRIPTION) + dTag(dTag) + kind(draft.kind) - if (draft is ExposeInDraft) { - addAll(draft.exposeInDraft()) - } + if (draft is ExposeInDraft) { + addAll(draft.exposeInDraft()) } - - signer.sign(template) { - it.addToCache(signer.pubKey, draft) - onReady(it) } - } + + return signer.sign(template) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt index d10fdc0bff..b982ca9fdd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt @@ -20,14 +20,23 @@ */ package com.vitorpamplona.quartz.nip37Drafts +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -35,7 +44,6 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class DraftEvent( @@ -45,77 +53,39 @@ class DraftEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var cachedInnerEvent: Map = mapOf() +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - override fun countMemory(): Long = - super.countMemory() + - 32 + (cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) }) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) override fun isContentEncoded() = true fun isDeleted() = content == "" - fun preCachedDraft(signer: NostrSigner): Event? = cachedInnerEvent[signer.pubKey] + fun canDecrypt(signer: NostrSigner) = signer.pubKey == pubKey - fun preCachedDraft(pubKey: HexKey): Event? = cachedInnerEvent[pubKey] + suspend fun createDeletedEvent(signer: NostrSigner): DraftEvent = signer.sign(createdAt, KIND, tags, "") - fun allCache() = cachedInnerEvent.values + suspend fun decryptInnerEvent(signer: NostrSigner): Event { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - fun addToCache( - pubKey: HexKey, - innerEvent: Event, - ) { - cachedInnerEvent = cachedInnerEvent + Pair(pubKey, innerEvent) - } - - fun cachedDraft( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - cachedInnerEvent[signer.pubKey]?.let { - onReady(it) - return - } - decrypt(signer) { draft -> - addToCache(signer.pubKey, draft) - - onReady(draft) - } - } - - private fun decrypt( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - try { - plainContent(signer) { - try { - onReady(fromJson(it)) - } catch (e: Exception) { - // Log.e("UnwrapError", "Couldn't Decrypt the content", e) - } - } - } catch (e: Exception) { - // Log.e("UnwrapError", "Couldn't Decrypt the content", e) - } - } - - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return - - signer.nip44Decrypt(content, pubKey, onReady) - } - - fun createDeletedEvent( - signer: NostrSigner, - onReady: (DraftEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, tags, "") { - onReady(it) + val json = signer.nip44Decrypt(content, pubKey) + return try { + fromJson(json) + } catch (e: JsonParseException) { + Log.w("DraftEvent", "Unable to parse inner event of a draft: $json") + throw e } } @@ -128,123 +98,116 @@ class DraftEvent( dTag: String, ): String = Address.assemble(KIND, pubKey, dTag) - fun create( + @Suppress("DEPRECATION") + suspend fun create( dTag: String, originalNote: TorrentCommentEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") } - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: InteractiveStoryBaseEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: LiveActivitiesChatMessageEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag(), "", "root")) } originalNote.replyingTo()?.let { tags.add(arrayOf("e", it, "", "reply")) } - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: ChannelMessageEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() originalNote.channelId()?.let { tags.add(arrayOf("e", it)) } - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + @Suppress("DEPRECATION") + suspend fun create( dTag: String, originalNote: GitReplyEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() originalNote.repository()?.let { tags.add(arrayOf("a", it.toTag())) } originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) } - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: PollNoteEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") } - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: CommentEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.rootScopes() + originalNote.directReplies() - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: TextNoteEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") } - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, innerEvent: Event, anchorTagArray: List> = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() tags.add(arrayOf("d", dTag)) tags.add(arrayOf("k", "${innerEvent.kind}")) @@ -253,12 +216,15 @@ class DraftEvent( tags.addAll(anchorTagArray) } - signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey) { encryptedContent -> - signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent) { - it.addToCache(signer.pubKey, innerEvent) - onReady(it) - } - } + val draft = + signer.sign( + createdAt = createdAt, + kind = KIND, + tags = tags.toTypedArray(), + content = signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey), + ) + + return draft } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEventCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEventCache.kt new file mode 100644 index 0000000000..ef14999b3e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEventCache.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip37Drafts + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class DraftEventCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(1000) { + override fun create(key: DraftEvent): DraftEventDecryptCache? = + if (!key.isDeleted() && key.pubKey == signer.pubKey) { + DraftEventDecryptCache(signer) + } else { + null + } + } + + fun delete(event: DraftEvent) = decryptionCache.remove(event) + + fun preload( + event: DraftEvent, + result: Event, + ) = decryptionCache[event]?.preload(result) + + fun preCachedDraft(event: DraftEvent): Event? = decryptionCache[event]?.cached() + + suspend fun cachedDraft(event: DraftEvent) = decryptionCache[event]?.decrypt(event) +} + +class DraftEventDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: DraftEvent, + signer: NostrSigner, + ): Event = event.decryptInnerEvent(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt new file mode 100644 index 0000000000..05a93432b7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip37Drafts.privateOutbox + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PrivateOutboxRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.mapNotNull(RelayTag::parse) + + suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.mapNotNull(RelayTag::parse) + + suspend fun relays(signer: NostrSigner): List = publicRelays() + (privateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10013 + const val FIXED_D_TAG = "" + + val ALT = "Relay list to store private content from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + suspend fun updateRelayList( + earlierVersion: PrivateOutboxRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PrivateOutboxRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PrivateOutboxRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): PrivateOutboxRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + privateRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt index 43eb9d1b0e..17aed53f05 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt @@ -41,42 +41,39 @@ class StatusEvent( companion object { const val KIND = 30315 - fun create( + suspend fun create( msg: String, type: String, expiration: Long?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { + ): StatusEvent { val tags = mutableListOf>() tags.add(arrayOf("d", type)) expiration?.let { tags.add(arrayOf("expiration", it.toString())) } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } - fun update( + suspend fun update( event: StatusEvent, newStatus: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { + ): StatusEvent { val tags = event.tags - signer.sign(createdAt, KIND, tags, newStatus, onReady) + return signer.sign(createdAt, KIND, tags, newStatus) } - fun clear( + suspend fun clear( event: StatusEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { + ): StatusEvent { val msg = "" val tags = event.tags.filter { it.size > 1 && it[0] == "d" } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt index a253366fe2..beb40d9ccb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt @@ -21,15 +21,9 @@ package com.vitorpamplona.quartz.nip40Expiration import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.utils.TimeUtils -fun Event.expiration() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null - } +fun Event.expiration() = tags.expiration() -fun Event.isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() +fun Event.isExpired() = tags.isExpired() -fun Event.isExpirationBefore(time: Long) = (expiration() ?: Long.MAX_VALUE) < time +fun Event.isExpirationBefore(time: Long) = tags.isExpirationBefore(time) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt new file mode 100644 index 0000000000..92c029f11e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ExpirationTag { + companion object { + const val TAG_NAME = "expiration" + + @JvmStatic + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..79e46448d5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.expiration(time: Long) = addUnique(ExpirationTag.assemble(time)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt new file mode 100644 index 0000000000..2ddaa8aa31 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.TimeUtils + +fun TagArray.expiration() = this.firstNotNullOfOrNull(ExpirationTag::parse) + +fun TagArray.isExpired(): Boolean { + val exp = expiration() ?: return false + return exp < TimeUtils.now() +} + +fun TagArray.isExpirationBefore(time: Long): Boolean { + val exp = expiration() ?: return false + return exp < time +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index e83a8bc1b6..acd65b79ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.nip42RelayAuth import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag +import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,45 +38,41 @@ class RelayAuthEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relay() = tags.firstOrNull { it.size > 1 && it[0] == "relay" }?.get(1) + fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun challenge() = tags.firstOrNull { it.size > 1 && it[0] == "challenge" }?.get(1) + fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) companion object { const val KIND = 22242 - fun create( - relay: String, + suspend fun create( + relay: NormalizedRelayUrl, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { + ): RelayAuthEvent { val content = "" val tags = arrayOf( - arrayOf("relay", relay), - arrayOf("challenge", challenge), + RelayTag.assemble(relay), + ChallengeTag.assemble(challenge), ) - signer.sign(createdAt, KIND, tags, content, onReady) + return signer.sign(createdAt, KIND, tags, content) } - fun create( - relays: List, + suspend fun create( + relays: List, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { + ): RelayAuthEvent { val content = "" val tags = relays - .map { - arrayOf("relay", it) - }.plusElement( - arrayOf("challenge", challenge), - ).toTypedArray() - signer.sign(createdAt, KIND, tags, content, onReady) + .map { RelayTag.assemble(it) } + .plusElement(ChallengeTag.assemble(challenge)) + .toTypedArray() + return signer.sign(createdAt, KIND, tags, content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt new file mode 100644 index 0000000000..2cf0d5fae5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip42RelayAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ChallengeTag { + companion object { + const val TAG_NAME = "challenge" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt new file mode 100644 index 0000000000..bce30729f4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip42RelayAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt index 69c301612f..43a2b2909c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -20,8 +20,7 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import java.util.Base64 @@ -49,8 +48,8 @@ object Nip44 { payload: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { - if (payload.isEmpty()) return null + ): String { + require(payload.isNotBlank()) { "Payload must not be blank" } return if (payload[0] == '{') { decryptNIP44FromJackson(payload, privateKey, pubKey) } else { @@ -62,14 +61,13 @@ object Nip44 { json: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { + ): String { // Ignores if it is not a valid json val info = try { - EventMapper.mapper.readValue(json, EncryptedInfoString::class.java) + JsonMapper.mapper.readValue(json, EncryptedInfoString::class.java) } catch (e: Exception) { - Log.e("NIP44", "Unable to parse json $json") - return null + throw IllegalArgumentException("Unable to parse NIP-44 JSON: $json") } return when (info.v) { @@ -101,31 +99,25 @@ object Nip44 { v2.decrypt(encryptedInfo, privateKey, pubKey) } - else -> null + else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${info.v}") } } private fun decryptNIP44FromBase64( - payload: String, + ciphertext: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { - if (payload.isEmpty()) return null + ): String { + require(ciphertext.isNotBlank()) { "ciphertext must not be blank" } // Ignores if it is not base64 - val byteArray = - try { - Base64.getDecoder().decode(payload) - } catch (e: Exception) { - Log.e("NIP44", "Unable to parse base64 $payload") - return null - } + val byteArray = Base64.getDecoder().decode(ciphertext) return when (byteArray[0].toInt()) { - EncryptedInfo.V -> Nip04.decrypt(payload, privateKey, pubKey) - Nip44v1.EncryptedInfo.V -> v1.decrypt(payload, privateKey, pubKey) - Nip44v2.EncryptedInfo.V -> v2.decrypt(payload, privateKey, pubKey) - else -> null + EncryptedInfo.V -> Nip04.decrypt(ciphertext, privateKey, pubKey) + Nip44v1.EncryptedInfo.V -> v1.decrypt(ciphertext, privateKey, pubKey) + Nip44v2.EncryptedInfo.V -> v2.decrypt(ciphertext, privateKey, pubKey) + else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${byteArray[0].toInt()}") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt index 942ecf47c0..d7f6824722 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CancellationException import java.util.Base64 class Nip44v1 { @@ -57,7 +57,7 @@ class Nip44v1 { ) return EncryptedInfo( - ciphertext = cipher ?: ByteArray(0), + ciphertext = cipher, nonce = nonce, ) } @@ -66,7 +66,7 @@ class Nip44v1 { payload: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { + ): String { val sharedSecret = getSharedSecret(privateKey, pubKey) return decrypt(payload, sharedSecret) } @@ -75,7 +75,7 @@ class Nip44v1 { encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray, - ): String? { + ): String { val sharedSecret = getSharedSecret(privateKey, pubKey) return decrypt(encryptedInfo, sharedSecret) } @@ -83,21 +83,21 @@ class Nip44v1 { fun decrypt( payload: String, sharedSecret: ByteArray, - ): String? { - val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null + ): String { + val encryptedInfo = EncryptedInfo.decodePayload(payload) return decrypt(encryptedInfo, sharedSecret) } fun decrypt( encryptedInfo: EncryptedInfo, sharedSecret: ByteArray, - ): String? = + ): String = LibSodiumInstance .cryptoStreamXChaCha20Xor( messageBytes = encryptedInfo.ciphertext, nonce = encryptedInfo.nonce, key = sharedSecret, - )?.decodeToString() + ).decodeToString() fun getSharedSecret( privateKey: ByteArray, @@ -127,19 +127,18 @@ class Nip44v1 { companion object { const val V: Int = 1 - fun decodePayload(payload: String): EncryptedInfo? { - return try { + fun decodePayload(payload: String): EncryptedInfo = + try { val byteArray = Base64.getDecoder().decode(payload) check(byteArray[0].toInt() == V) - return EncryptedInfo( + EncryptedInfo( nonce = byteArray.copyOfRange(1, 25), ciphertext = byteArray.copyOfRange(25, byteArray.size), ) } catch (e: Exception) { - Log.w("NIP44v1", "Unable to Parse encrypted payload: $payload") - null + if (e is CancellationException) throw e + throw IllegalStateException("NIP-44v1 Unable to Parse encrypted payload: $payload", e) } - } } fun encodePayload(): String = diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 594658ac6c..e0ca8c2bd0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.CancellationException import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Base64 @@ -88,26 +88,26 @@ class Nip44v2 { payload: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? = decrypt(payload, getConversationKey(privateKey, pubKey)) + ): String = decrypt(payload, getConversationKey(privateKey, pubKey)) fun decrypt( decoded: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray, - ): String? = decrypt(decoded, getConversationKey(privateKey, pubKey)) + ): String = decrypt(decoded, getConversationKey(privateKey, pubKey)) fun decrypt( payload: String, conversationKey: ByteArray, - ): String? { - val decoded = EncryptedInfo.decodePayload(payload) ?: return null + ): String { + val decoded = EncryptedInfo.decodePayload(payload) return decrypt(decoded, conversationKey) } fun decrypt( decoded: EncryptedInfo, conversationKey: ByteArray, - ): String? { + ): String { val messageKey = getMessageKeys(conversationKey, decoded.nonce) val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) @@ -236,7 +236,7 @@ class Nip44v2 { companion object { const val V: Int = 2 - fun decodePayload(payload: String): EncryptedInfo? { + fun decodePayload(payload: String): EncryptedInfo { check(payload.length >= 132 || payload.length <= 87472) { "Invalid payload length ${payload.length} for $payload" } @@ -245,14 +245,14 @@ class Nip44v2 { return try { val byteArray = Base64.getDecoder().decode(payload) check(byteArray[0].toInt() == V) - return EncryptedInfo( + EncryptedInfo( nonce = byteArray.copyOfRange(1, 33), ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), mac = byteArray.copyOfRange(byteArray.size - 32, byteArray.size), ) } catch (e: Exception) { - Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload") - null + if (e is CancellationException) throw e + throw IllegalStateException("NIP-44v2 Unable to Parse encrypted payload: $payload", e) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt index d8e0d7e433..a7df25d165 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt @@ -26,13 +26,12 @@ import java.util.UUID class BunkerResponseEvent( id: String = UUID.randomUUID().toString(), val event: Event, -) : com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse(id, event.toJson(), null) { +) : BunkerResponse(id, event.toJson(), null) { companion object { fun parse( id: String, result: String, error: String? = null, - ) = com.vitorpamplona.quartz.nip46RemoteSigner - .BunkerResponseEvent(id, Event.fromJson(result)) + ) = BunkerResponseEvent(id, Event.fromJson(result)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt index 3b497a45b2..6ed8c94e84 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt @@ -21,19 +21,19 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import com.fasterxml.jackson.core.type.TypeReference -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import java.util.UUID class BunkerResponseGetRelays( id: String = UUID.randomUUID().toString(), val relays: Map, -) : BunkerResponse(id, EventMapper.mapper.writeValueAsString(relays), null) { +) : BunkerResponse(id, JsonMapper.mapper.writeValueAsString(relays), null) { companion object { fun parse( id: String, result: String, error: String? = null, - ) = BunkerResponseGetRelays(id, EventMapper.mapper.readValue(result, object : TypeReference>() {})) + ) = BunkerResponseGetRelays(id, JsonMapper.mapper.readValue(result, object : TypeReference>() {})) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt index d092ea8fae..cc07e06850 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class NostrConnectEvent( @@ -39,17 +39,18 @@ class NostrConnectEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var decryptedContent: Map = mapOf() - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.countMemory() }) - override fun isContentEncoded() = true - private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey - fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() + suspend fun decryptMessage(signer: NostrSigner): BunkerMessage { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + + val retVal = signer.decrypt(content, talkingWith(signer.pubKey)) + return JsonMapper.mapper.readValue(retVal, BunkerMessage::class.java) + } + + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) fun verifiedRecipientPubKey(): HexKey? { val recipient = recipientPubKey() @@ -62,47 +63,29 @@ class NostrConnectEvent( fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey - fun plainContent( - signer: NostrSigner, - onReady: (BunkerMessage) -> Unit, - ) { - decryptedContent[signer.pubKey]?.let { - onReady(it) - return - } - - // decrypts using NIP-04 or NIP-44 - signer.decrypt(content, talkingWith(signer.pubKey)) { retVal -> - val content = EventMapper.mapper.readValue(retVal, BunkerMessage::class.java) - - decryptedContent = decryptedContent + Pair(signer.pubKey, content) - - onReady(content) - } - } - companion object { const val KIND = 24133 const val ALT = "Nostr Connect Event" - fun create( + suspend fun create( message: BunkerMessage, remoteKey: HexKey, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (NostrConnectEvent) -> Unit, - ) { - val tags = - arrayOf( - AltTag.assemble(ALT), - arrayOf("p", remoteKey), - ) - - val encrypted = EventMapper.mapper.writeValueAsString(message) - - signer.nip44Encrypt(encrypted, remoteKey) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } - } + ): NostrConnectEvent = + signer.sign( + createdAt = createdAt, + kind = KIND, + tags = + arrayOf( + AltTag.assemble(ALT), + arrayOf("p", remoteKey), + ), + content = + signer.nip44Encrypt( + plaintext = JsonMapper.mapper.writeValueAsString(message), + toPublicKey = remoteKey, + ), + ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt index 064a934de6..919bc33a89 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt @@ -20,16 +20,14 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class LnZapPaymentRequestEvent( @@ -40,60 +38,41 @@ class LnZapPaymentRequestEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient private var lnInvoice: String? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (lnInvoice?.bytesUsedInMemory() ?: 0) // rough calculation + override fun isContentEncoded() = true fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey - fun lnInvoice( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - lnInvoice?.let { - onReady(it) - return - } + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || walletServicePubKey() == signer.pubKey - try { - signer.decrypt(content, talkingWith(signer.pubKey)) { jsonText -> - val payInvoiceMethod = EventMapper.mapper.readValue(jsonText, Request::class.java) - - lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice - - lnInvoice?.let { onReady(it) } - } - } catch (e: Exception) { - Log.w("BookmarkList", "Error decrypting the message ${e.message}") - } + suspend fun decryptRequest(signer: NostrSigner): Request { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + val jsonText = signer.decrypt(content, talkingWith(signer.pubKey)) + return JsonMapper.mapper.readValue(jsonText, Request::class.java) } companion object { const val KIND = 23194 const val ALT = "Zap payment request" - fun create( + suspend fun create( lnInvoice: String, walletServicePubkey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (LnZapPaymentRequestEvent) -> Unit, - ) { - val serializedRequest = EventMapper.mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) + ): LnZapPaymentRequestEvent { + val serializedRequest = JsonMapper.mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) - signer.nip04Encrypt( - serializedRequest, - walletServicePubkey, - ) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } + val encrypted = + signer.nip04Encrypt( + serializedRequest, + walletServicePubkey, + ) + + return signer.sign(createdAt, KIND, tags, encrypted) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt index e2a2272367..1f019bac67 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt @@ -20,13 +20,12 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Immutable class LnZapPaymentResponseEvent( @@ -37,10 +36,7 @@ class LnZapPaymentResponseEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient private var response: Response? = null - - override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (response?.countMemory() ?: 0) + override fun isContentEncoded() = true fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) @@ -48,38 +44,13 @@ class LnZapPaymentResponseEvent( fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - try { - signer.decrypt(content, talkingWith(signer.pubKey)) { content -> onReady(content) } - } catch (e: Exception) { - Log.w("PrivateDM", "Error decrypting the message ${e.message}") - } - } + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || requestAuthor() == signer.pubKey - fun response( - signer: NostrSigner, - onReady: (Response) -> Unit, - ) { - response?.let { - onReady(it) - return - } + suspend fun decrypt(signer: NostrSigner): Response { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - try { - if (content.isNotEmpty()) { - plainContent(signer) { - EventMapper.mapper.readValue(it, Response::class.java)?.let { - response = it - onReady(it) - } - } - } - } catch (e: Exception) { - Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) - } + val json = signer.decrypt(content, talkingWith(signer.pubKey)) + return JsonMapper.mapper.readValue(json, Response::class.java) } companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index 2525ca7dcc..c3cf1add28 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -21,15 +21,19 @@ package com.vitorpamplona.quartz.nip47WalletConnect import androidx.core.net.toUri +import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import kotlinx.coroutines.CancellationException // Rename to the corect nip number when ready. class Nip47WalletConnect { companion object { - fun parse(uri: String): Nip47URI { + fun parse(uri: String): Nip47URINorm { // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D val url = uri.toUri() @@ -49,9 +53,10 @@ class Nip47WalletConnect { } val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null") + val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url") val secret = url.getQueryParameter("secret") - return Nip47URI(pubkeyHex, relay, secret) + return Nip47URINorm(pubkeyHex, relayNorm, secret) } } @@ -59,5 +64,28 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: String, val secret: HexKey?, - ) + ) { + fun normalize(): Nip47URINorm? = + RelayUrlNormalizer.normalizeOrNull(relayUri)?.let { + Nip47URINorm( + pubKeyHex, + it, + secret, + ) + } + + companion object { + fun parser(json: String) = JsonMapper.mapper.readValue(json) + + fun serializer(value: Nip47URI) = JsonMapper.mapper.writeValueAsString(value) + } + } + + data class Nip47URINorm( + val pubKeyHex: HexKey, + val relayUri: NormalizedRelayUrl, + val secret: HexKey?, + ) { + fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt new file mode 100644 index 0000000000..890fce7ac5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class NostrWalletConnectRequestCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(50) { + override fun create(key: LnZapPaymentRequestEvent): NWCRequestDecryptCache? = + if (key.content.isNotBlank() && key.canDecrypt(signer)) { + NWCRequestDecryptCache(signer) + } else { + null + } + } + + fun cachedRequest(event: LnZapPaymentRequestEvent): Request? = decryptionCache[event]?.cached() + + suspend fun decryptRequest(event: LnZapPaymentRequestEvent) = decryptionCache[event]?.decrypt(event) +} + +class NWCRequestDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: LnZapPaymentRequestEvent, + signer: NostrSigner, + ) = event.decryptRequest(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt new file mode 100644 index 0000000000..d4b31fb67f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class NostrWalletConnectResponseCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(50) { + override fun create(key: LnZapPaymentResponseEvent): NWCResponseDecryptCache? = + if (key.content.isNotBlank() && key.canDecrypt(signer)) { + NWCResponseDecryptCache(signer) + } else { + null + } + } + + fun cachedResponse(event: LnZapPaymentResponseEvent): Response? = decryptionCache[event]?.cached() + + suspend fun decryptResponse(event: LnZapPaymentResponseEvent) = decryptionCache[event]?.decrypt(event) +} + +class NWCResponseDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: LnZapPaymentResponseEvent, + signer: NostrSigner, + ) = event.decrypt(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt index b43cdf53fb..6dd52abc33 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -21,14 +21,27 @@ package com.vitorpamplona.quartz.nip50Search import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.searchRelays +import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus +import kotlin.collections.toTypedArray @Immutable class SearchRelayListEvent( @@ -38,73 +51,72 @@ class SearchRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun relays(signer: NostrSigner): List = publicRelays() + (privateRelays(signer) ?: emptyList()) companion object { const val KIND = 10007 + val ALT = "Relay list to use for Search" + val ALT_TAG = arrayOf(AltTag.assemble(ALT)) - fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey) - fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey) - fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey) - fun createTagArray(relays: List): Array> = - relays - .map { - arrayOf("relay", it) - }.plusElement(AltTag.assemble("Relay list to use for Search")) - .toTypedArray() - - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: SearchRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter { it[0] != "relay" } - .plus( - relays.map { - arrayOf("relay", it) - }, - ).toTypedArray() + ): SearchRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) } - fun createFromScratch( - relays: List, + suspend fun create( + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) + ): SearchRelayListEvent { + val publicTagArray = relays.map { RelayTag.assemble(it) }.plus(ALT_TAG).toTypedArray() + return signer.signNip51List(createdAt, KIND, publicTagArray, emptyArray()) } fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } - - fun create( - relays: List, + relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") + ): SearchRelayListEvent { + val publicTagArray = relays.map { RelayTag.assemble(it) }.plus(ALT_TAG).toTypedArray() + return signer.signNip51List(createdAt, KIND, publicTagArray, emptyArray()) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + searchRelays(publicRelays) + + initializer() + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt new file mode 100644 index 0000000000..e583e4ab0f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip50Search + +interface SearchableEvent { + fun indexableContent(): String +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt deleted file mode 100644 index ee43e98ec0..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class BookmarkListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun countBookmarks() = tags.count(ETag::isTagged) + tags.count(ATag::isTagged) - - companion object { - const val KIND = 30001 - const val ALT = "List of bookmarks" - const val DEFAULT_D_TAG_BOOKMARKS = "bookmark" - - fun addEvent( - earlierVersion: BookmarkListEvent?, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - - fun addReplaceable( - earlierVersion: BookmarkListEvent?, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: BookmarkListEvent?, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - add( - earlierVersion, - arrayOf(arrayOf(tagName, tagValue)), - isPrivate, - signer, - createdAt, - onReady, - ) - } - - fun add( - earlierVersion: BookmarkListEvent?, - listNewTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - if (isPrivate) { - if (earlierVersion != null) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(listNewTags), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - encryptTags( - privateTags = listNewTags, - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = arrayOf(arrayOf("d", DEFAULT_D_TAG_BOOKMARKS)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion?.content ?: "", - tags = (earlierVersion?.tags ?: arrayOf(arrayOf("d", DEFAULT_D_TAG_BOOKMARKS))).plus(listNewTags), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun removeEvent( - earlierVersion: BookmarkListEvent, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - - fun removeReplaceable( - earlierVersion: BookmarkListEvent, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - - private fun removeTag( - earlierVersion: BookmarkListEvent, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - - fun create( - name: String = "", - events: List? = null, - users: List? = null, - addresses: List? = null, - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - - events?.forEach { tags.add(arrayOf("e", it)) } - users?.forEach { tags.add(arrayOf("p", it)) } - addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } - tags.add(AltTag.assemble(ALT)) - - createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt deleted file mode 100644 index e50c41beb3..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag -import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag -import com.vitorpamplona.quartz.nip51Lists.tags.NameTag -import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -abstract class GeneralListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig), - EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ETag::parseAsHint) ?: emptyList()) - - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ATag::parseAsHint) ?: emptyList()) - - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(PTag::parseAsHint) ?: emptyList()) - - fun name() = tags.firstNotNullOfOrNull(NameTag::parse) - - @Deprecated("NIP-51 has deprecated Title. Use name instead", ReplaceWith("name()")) - fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - - fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) - - fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - - fun nameOrTitle() = name() ?: title() - - fun filterTagList( - key: String, - privateTags: Array>?, - ): ImmutableSet { - val result = HashSet(tags.size + (privateTags?.size ?: 0)) - - privateTags?.let { it.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } } - - tags.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } - - return result.toImmutableSet() - } - - fun isTagged( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = if (isPrivate) { - privateTagsOrEmpty(signer = signer) { - onReady( - it.any { it.size > 1 && it[0] == key && it[1] == tag }, - ) - } - } else { - onReady(tags.isTagged(key, tag)) - } - - fun privateTagsOrEmpty( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - privateTags(signer, onReady) - } - - fun privateTaggedUsers( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterUsers(it)) } - - fun privateHashtags( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterHashtags(it)) } - - fun privateGeohashes( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterGeohashes(it)) } - - fun privateTaggedEvents( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterEvents(it)) } - - fun privateATags( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterATags(it)) } - - fun privateAddress( - signer: NostrSigner, - onReady: (List
) -> Unit, - ) = privateTags(signer) { onReady(filterAddresses(it)) } - - fun filterUsers(tags: Array>): List = tags.mapNotNull(PTag::parseKey) - - fun filterHashtags(tags: Array>): List = tags.mapNotNull(HashtagTag::parse) - - fun filterGeohashes(tags: Array>): List = tags.geohashes() - - fun filterEvents(tags: Array>): List = tags.mapNotNull(ETag::parseId) - - fun filterATags(tags: Array>): List = tags.mapNotNull(ATag::parse) - - fun filterAddresses(tags: Array>): List
= tags.mapNotNull(ATag::parseAddress) - - companion object { - fun createPrivateTags( - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val privTags = mutableListOf>() - privEvents?.forEach { privTags.add(arrayOf("e", it)) } - privUsers?.forEach { privTags.add(arrayOf("p", it)) } - privAddresses?.forEach { privTags.add(arrayOf("a", it.toTag())) } - - return encryptTags(privTags.toTypedArray(), signer, onReady) - } - - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val msg = EventMapper.mapper.writeValueAsString(privateTags) - - signer.nip04Encrypt( - msg, - signer.pubKey, - onReady, - ) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt deleted file mode 100644 index fb56d48f8a..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class MuteListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - override fun dTag() = FIXED_D_TAG - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (PeopleListEvent.UsersAndWords) -> Unit, - ) { - privateTagsOrEmpty(signer) { - onReady( - PeopleListEvent.UsersAndWords(filterTagList("p", it), filterTagList("word", it)), - ) - } - } - - companion object { - const val KIND = 10000 - const val FIXED_D_TAG = "" - const val ALT = "Mute List" - - fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) - - fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:" - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithUser( - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun createListWithWord( - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = createListWithTag("word", word, isPrivate, signer, createdAt, onReady) - - fun addUsers( - earlierVersion: MuteListEvent, - listPubKeyHex: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addWord( - earlierVersion: MuteListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - - fun addUser( - earlierVersion: MuteListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: MuteListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeWord( - earlierVersion: MuteListEvent, - word: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady) - - fun removeUser( - earlierVersion: MuteListEvent, - pubKeyHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: MuteListEvent, - key: String, - tag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt deleted file mode 100644 index 568c7fbddd..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class PeopleListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Immutable - class UsersAndWords( - val users: Set = setOf(), - val words: Set = setOf(), - ) - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (UsersAndWords) -> Unit, - ) { - privateTagsOrEmpty(signer) { - onReady( - UsersAndWords( - filterTagList("p", it), - filterTagList("word", it), - ), - ) - } - } - - fun isTaggedWord( - word: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = isTagged("word", word, isPrivate, signer, onReady) - - fun isTaggedUser( - idHex: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = isTagged("p", idHex, isPrivate, signer, onReady) - - companion object { - const val KIND = 30000 - const val BLOCK_LIST_D_TAG = "mute" - const val ALT = "List of people" - - fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG) - - fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" - - fun createListWithTag( - name: String, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = arrayOf(arrayOf("d", name)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithUser( - name: String, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun createListWithWord( - name: String, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady) - - fun addUsers( - earlierVersion: PeopleListEvent, - listPubKeyHex: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addWord( - earlierVersion: PeopleListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - - fun addUser( - earlierVersion: PeopleListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: PeopleListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeWord( - earlierVersion: PeopleListEvent, - word: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady) - - fun removeUser( - earlierVersion: PeopleListEvent, - pubKeyHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: PeopleListEvent, - key: String, - tag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt index 0fdc7bd2c0..e92935e66f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt @@ -42,17 +42,16 @@ class PinListEvent( const val KIND = 33888 const val ALT = "Pinned Posts" - fun create( + suspend fun create( pins: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PinListEvent) -> Unit, - ) { + ): PinListEvent { val tags = mutableListOf>() pins.forEach { tags.add(arrayOf("pin", it)) } tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt index 6152aa5652..453a7b44a6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt @@ -21,194 +21,170 @@ package com.vitorpamplona.quartz.nip51Lists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent class PrivateTagArrayBuilder { companion object { - fun create( + suspend fun create( tags: Array>, toPrivate: Boolean, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { + ): Pair>> = if (toPrivate) { - PrivateTagsInContent.encryptNip04( - privateTags = tags, - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, arrayOf()) - } + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = tags, + signer = signer, + ) + Pair(encryptedTags, arrayOf()) } else { - onReady("", tags) + Pair("", tags) } - } - fun add( + suspend fun add( current: PrivateTagArrayEvent, newTag: Array, toPrivate: Boolean, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { + ): Pair>> = if (toPrivate) { - current.privateTags(signer) { privateTags -> + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = PrivateTagsInContent.encryptNip04( privateTags = privateTags.plus(newTag), signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } + ) + Pair(encryptedTags, current.tags) } else { - onReady(current.content, current.tags.plus(newTag)) + Pair(current.content, current.tags.plus(newTag)) } - } - fun addAll( + suspend fun addAll( current: PrivateTagArrayEvent, newTag: Array>, toPrivate: Boolean, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { + ): Pair>> = if (toPrivate) { - current.privateTags(signer) { privateTags -> + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = PrivateTagsInContent.encryptNip04( privateTags = privateTags.plus(newTag), signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } + ) + Pair(encryptedTags, current.tags) } else { - onReady(current.content, current.tags.plus(newTag)) + Pair(current.content, current.tags.plus(newTag)) } - } - fun replaceAllToPrivateNewTag( + suspend fun replaceAllToPrivateNewTag( dTag: String, current: PrivateTagArrayEvent?, oldTagStartsWith: Array, newTag: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { + ): Pair>> = if (current == null) { - createPrivate(dTag, newTag, signer, onReady) + createPrivate(dTag, newTag, signer) } else { - replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady) + replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer) } - } - fun replaceAllToPublicNewTag( + suspend fun replaceAllToPublicNewTag( dTag: String, current: PrivateTagArrayEvent?, oldTagStartsWith: Array, newTag: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { + ): Pair>> = if (current == null) { - createPublic(dTag, newTag, signer, onReady) + createPublic(dTag, newTag, signer) } else { - replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady) + replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer) } - } - fun replaceAllToPrivateNewTag( + suspend fun replaceAllToPrivateNewTag( current: PrivateTagArrayEvent, oldTagStartsWith: Array, newTag: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = PrivateTagsInContent.encryptNip04( privateTags = privateTags.replaceAll(oldTagStartsWith, newTag), signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags.remove(oldTagStartsWith)) - } - } + ) + return Pair(encryptedTags, current.tags.remove(oldTagStartsWith)) } - fun replaceAllToPublicNewTag( + suspend fun replaceAllToPublicNewTag( current: PrivateTagArrayEvent, oldTagStartsWith: Array, newTag: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = PrivateTagsInContent.encryptNip04( privateTags = privateTags.remove(oldTagStartsWith), signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag)) - } - } + ) + return Pair(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag)) } - fun removeAllFromPrivate( + suspend fun removeAllFromPrivate( current: PrivateTagArrayEvent, oldTagStartsWith: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = PrivateTagsInContent.encryptNip04( privateTags = privateTags.remove(oldTagStartsWith), signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } + ) + return Pair(encryptedTags, current.tags) } fun removeAllFromPublic( current: PrivateTagArrayEvent, oldTagStartsWith: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) = onReady(current.content, current.tags.remove(oldTagStartsWith)) + ): Pair>> = Pair(current.content, current.tags.remove(oldTagStartsWith)) - fun removeAll( + suspend fun removeAll( current: PrivateTagArrayEvent, oldTagStartsWith: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = PrivateTagsInContent.encryptNip04( privateTags = privateTags.remove(oldTagStartsWith), signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags.remove(oldTagStartsWith)) - } - } + ) + return Pair(encryptedTags, current.tags.remove(oldTagStartsWith)) } - fun createPrivate( + suspend fun createPrivate( dTag: String, newTag: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - PrivateTagsInContent.encryptNip04( - privateTags = arrayOf(newTag), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, arrayOf(arrayOf("d", dTag))) - } + ): Pair>> { + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = arrayOf(newTag), + signer = signer, + ) + return Pair(encryptedTags, arrayOf(arrayOf("d", dTag))) } fun createPublic( dTag: String, newTag: Array, signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - onReady("", arrayOf(arrayOf("d", dTag), newTag)) - } + ): Pair>> = Pair("", arrayOf(arrayOf("d", dTag), newTag)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index fba112dd91..12d70ed5c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.quartz.nip51Lists -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import kotlinx.coroutines.CancellationException @Immutable abstract class PrivateTagArrayEvent( @@ -35,61 +35,28 @@ abstract class PrivateTagArrayEvent( pubKey: HexKey, createdAt: Long, kind: Int, - tags: Array>, + tags: TagArray, content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient private var privateTagsCache: Array>? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) - override fun isContentEncoded() = true - fun cachedPrivateTags(): Array>? = privateTagsCache + suspend fun decrypt(signer: NostrSigner): TagArray { + if (signer.pubKey != pubKey) throw SignerExceptions.UnauthorizedDecryptionException() - fun privateTags( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - if (content.isEmpty()) { - onReady(emptyArray()) - return - } - - privateTagsCache?.let { - onReady(it) - return - } - - try { - PrivateTagsInContent.decrypt(content, signer) { - privateTagsCache = it - privateTagsCache?.let { onReady(it) } - } - } catch (e: Throwable) { - onReady(emptyArray()) - Log.w("GeneralList", "Error parsing the JSON ${e.message}") - } + return PrivateTagsInContent.decrypt(content, signer) } - fun mergeTagList( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - privateTags(signer) { - onReady(tags + it) + suspend fun privateTags(signer: NostrSigner): TagArray? { + if (signer.pubKey != pubKey) { + return null } - } - fun mapAllTags( - privateTags: Array>, - mapper: (Array) -> T, - ): Set { - val privateRooms = privateTags.mapNotNull(mapper) - val publicRooms = tags.mapNotNull(mapper) - - return (privateRooms + publicRooms).toSet() + return try { + PrivateTagsInContent.decrypt(content, signer) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEventCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEventCache.kt new file mode 100644 index 0000000000..e2ac34b1a7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEventCache.kt @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import kotlin.collections.plus + +class PrivateTagArrayEventCache( + signer: NostrSigner, + cacheSize: Int = 10, +) { + private val decryptionCache = + object : LruCache>(cacheSize) { + override fun create(key: T): PrivateTagArrayEventDecryptCache? = + if (key.content.isNotBlank() && key.pubKey == signer.pubKey) { + PrivateTagArrayEventDecryptCache(signer) + } else { + null + } + } + + fun remove(event: T) = decryptionCache.remove(event) + + fun cachedPrivateTags(event: T): TagArray? = decryptionCache[event]?.cached() + + suspend fun privateTags(event: T) = decryptionCache[event]?.decrypt(event) + + suspend fun mergeTagList(event: T): TagArray = event.tags + (privateTags(event) ?: emptyArray()) + + fun mergeTagListPrecached(event: T): TagArray = event.tags + (cachedPrivateTags(event) ?: emptyArray()) +} + +class PrivateTagArrayEventDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: T, + signer: NostrSigner, + ): TagArray = event.decrypt(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt index e364731150..0b03a2b19f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt @@ -20,8 +20,11 @@ */ package com.vitorpamplona.quartz.nip51Lists +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.utils.startsWith +import com.vitorpamplona.quartz.utils.startsWithAny +import kotlin.collections.ArrayList inline fun TagArray.filterToArray(predicate: (Array) -> Boolean): TagArray = filterTo(ArrayList(), predicate).toTypedArray() @@ -29,6 +32,25 @@ inline fun TagArray.remove(predicate: (Array) -> Boolean): TagArray = fi fun TagArray.remove(startsWith: Array): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray() +fun TagArray.removeParsing( + transform: (Tag) -> R, + equalsTo: R, +): TagArray = + filterNotTo( + destination = ArrayList(this.size), + predicate = { + transform(it) == equalsTo + }, + ).toTypedArray() + +fun TagArray.removeAny(startsWith: List>): TagArray = + filterNotTo( + ArrayList(this.size), + { + it.startsWithAny(startsWith) + }, + ).toTypedArray() + fun TagArray.replaceAll( startsWith: Array, newElement: Array, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt new file mode 100644 index 0000000000..2aa6f41dc3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.bookmarkList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BookmarkListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider { + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) + + override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun countBookmarks() = tags.count(BookmarkIdTag::isTagged) + + fun publicBookmarks(): List = tags.mapNotNull(BookmarkIdTag::parse) + + suspend fun privateBookmarks(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse) + + companion object { + const val KIND = 30001 + const val ALT = "List of bookmarks" + const val DEFAULT_D_TAG_BOOKMARKS = "bookmark" + + fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, DEFAULT_D_TAG_BOOKMARKS) + + suspend fun create( + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent = + if (isPrivate) { + create( + publicBookmarks = emptyList(), + privateBookmarks = listOf(bookmarkIdTag), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicBookmarks = listOf(bookmarkIdTag), + privateBookmarks = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: BookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: BookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = + earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + name: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + dTag: String = DEFAULT_D_TAG_BOOKMARKS, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent { + val template = build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) + return signer.sign(template) + } + + suspend fun build( + name: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + signer: NostrSigner, + dTag: String = DEFAULT_D_TAG_BOOKMARKS, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + bookmarks(publicBookmarks) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3fc8ebe461 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/TagArrayBuilderExt.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.bookmarkList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/AddressBookmark.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/AddressBookmark.kt new file mode 100644 index 0000000000..ede0ece456 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/AddressBookmark.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class AddressBookmark( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) : BookmarkIdTag { + fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + override fun toTagArray() = assemble(address, relayHint) + + override fun toTagIdOnly() = assemble(address, null) + + companion object { + const val TAG_NAME = "a" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: AddressBookmark, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parse( + aTagId: String, + relay: String?, + ) = Address.parse(aTagId)?.let { + AddressBookmark(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + } + + @JvmStatic + fun parse(tag: Array): AddressBookmark? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return parse(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + @JvmStatic + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1]) + } + + @JvmStatic + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt new file mode 100644 index 0000000000..a3f344e459 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag + +sealed interface BookmarkIdTag { + fun toTagArray(): Tag + + fun toTagIdOnly(): Tag + + companion object { + fun isTagged(tag: Array) = EventBookmark.isTagged(tag) || AddressBookmark.isTagged(tag) + + fun parse(tag: Array): BookmarkIdTag? = EventBookmark.parse(tag) ?: AddressBookmark.parse(tag) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/EventBookmark.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/EventBookmark.kt new file mode 100644 index 0000000000..8bfeb5a591 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/EventBookmark.kt @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +class EventBookmark( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, + val author: HexKey? = null, +) : BookmarkIdTag { + fun countMemory(): Long = + 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.url?.bytesUsedInMemory() ?: 0) + + (author?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + override fun toTagIdOnly() = assemble(eventId, null, null) + + companion object { + const val TAG_NAME = "e" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + @JvmStatic + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun parse(tag: Array): EventBookmark? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + return EventBookmark(tag[1], pickRelayHint(tag), pickAuthor(tag)) + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + return null + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt new file mode 100644 index 0000000000..7525a1a1d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.encryption + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync + +suspend fun NostrSigner.signNip51List( + createdAt: Long, + kind: Int, + tags: Array>, + privateTags: Array>, +) = sign( + createdAt = createdAt, + kind = kind, + tags = tags, + content = PrivateTagsInContent.encryptNip44(privateTags, this), +) + +fun NostrSignerSync.signNip51List( + createdAt: Long, + kind: Int, + tags: Array>, + privateTags: Array>, +) = sign( + createdAt = createdAt, + kind = kind, + tags = tags, + content = PrivateTagsInContent.encryptNip44(privateTags, this), +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt index 28215447ab..fec602548c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt @@ -20,44 +20,76 @@ */ package com.vitorpamplona.quartz.nip51Lists.encryption +import android.util.Log +import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync class PrivateTagsInContent { companion object { - fun decode(content: String) = EventMapper.mapper.readValue>>(content) + fun decode(content: String) = JsonMapper.mapper.readValue>>(content) - fun encode(privateTags: Array>) = EventMapper.mapper.writeValueAsString(privateTags) + fun encode(privateTags: Array>): String = JsonMapper.mapper.writeValueAsString(privateTags) - fun decrypt( + suspend fun decrypt( content: String, signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - signer.decrypt(content, signer.pubKey) { - onReady(decode(it)) + ): TagArray { + if (content.isBlank()) return emptyArray() + val json = signer.decrypt(content, signer.pubKey) + return try { + decode(json) + } catch (e: JsonParseException) { + Log.w("DraftEvent", "Unable to parse inner event of a draft: $json") + throw e } } + suspend fun encryptNip04( + privateTags: Array>? = null, + signer: NostrSigner, + ): String = + signer.nip04Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) + + suspend fun encryptNip44( + privateTags: Array>? = null, + signer: NostrSigner, + ): String = + signer.nip44Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) + + suspend fun decrypt( + content: String, + signer: NostrSignerSync, + ): TagArray { + val json = signer.decrypt(content, signer.pubKey) + return decode(json) + } + fun encryptNip04( privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) = signer.nip04Encrypt( - if (privateTags.isNullOrEmpty()) "" else encode(privateTags), - signer.pubKey, - onReady, - ) + signer: NostrSignerSync, + ): String = + signer.nip04Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) fun encryptNip44( privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) = signer.nip44Encrypt( - if (privateTags.isNullOrEmpty()) "" else encode(privateTags), - signer.pubKey, - onReady, - ) + signer: NostrSignerSync, + ): String = + signer.nip44Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/FollowListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt similarity index 55% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/FollowListEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt index 00fb55b4ed..fcecb05dbc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/FollowListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt @@ -18,19 +18,27 @@ * 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.quartz.nip51Lists +package com.vitorpamplona.quartz.nip51Lists.followList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag -import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID +import kotlin.collections.plus @Immutable class FollowListEvent( @@ -40,102 +48,120 @@ class FollowListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun pubKeys() = tags.mapNotNull(PTag::parseKey) +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) - fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - - fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) - fun nameOrTitle() = name() ?: title() - fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + fun follows() = tags.follows() + + fun followIds() = tags.followIds() + + fun followIdSet() = tags.followIdSet() + companion object { const val KIND = 39089 const val ALT = "List of people to follow" - fun createListWithUser( + suspend fun create( name: String, - pubKeyHex: String, + person: UserTag, signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), - onReady: (FollowListEvent) -> Unit, - ) { + ): FollowListEvent = create( - content = "", - tags = arrayOf(arrayOf("d", name), arrayOf("p", pubKeyHex)), + name = name, + people = listOf(person), signer = signer, + dTag = dTag, createdAt = createdAt, - onReady = onReady, ) - } - fun addUsers( + suspend fun addUsers( earlierVersion: FollowListEvent, - listPubKeyHex: List, + people: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FollowListEvent) -> Unit, - ) { - create( + ): FollowListEvent = + resign( content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), + tags = earlierVersion.tags.plus(people.map { it.toTagArray() }), signer = signer, createdAt = createdAt, - onReady = onReady, ) - } - fun addUser( + suspend fun add( earlierVersion: FollowListEvent, - pubKeyHex: String, + person: UserTag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FollowListEvent) -> Unit, - ) = addUsers(earlierVersion, listOf(pubKeyHex), signer, createdAt, onReady) + ) = addUsers(earlierVersion, listOf(person), signer, createdAt) - fun removeUser( + suspend fun remove( earlierVersion: FollowListEvent, - pubKeyHex: String, + person: UserTag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FollowListEvent) -> Unit, - ) { - create( + ): FollowListEvent = + resign( content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == "p" && it[1] == pubKeyHex) } - .toTypedArray(), + tags = earlierVersion.tags.remove(person.toTagIdOnly()), signer = signer, createdAt = createdAt, - onReady = onReady, ) - } - fun create( + suspend fun resign( content: String, tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FollowListEvent) -> Unit, - ) { + ): FollowListEvent { val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { + if (tags.fastAny(AltTag::match)) { tags } else { tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + name: String, + people: List = emptyList(), + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): FollowListEvent { + val template = build(name, people, dTag, createdAt) + return signer.sign(template) + } + + fun build( + name: String, + people: List = emptyList(), + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = "", + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + people(people) + + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..511395f544 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.followList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.people(peoples: List) = addAll(peoples.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt new file mode 100644 index 0000000000..191b6e869f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.followList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag + +fun TagArray.follows() = mapNotNull(UserTag::parse) + +fun TagArray.followIds() = mapNotNull(UserTag::parseKey) + +fun TagArray.followIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt new file mode 100644 index 0000000000..622e2767e3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt @@ -0,0 +1,220 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.geohashList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GeohashListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicGeohashes() = tags.geohashList() + + suspend fun decryptPrivateGeohashes(signer: NostrSigner) = privateTags(signer)?.geohashList() + + suspend fun decryptGeohashes(signer: NostrSigner): List = publicGeohashes() + (decryptPrivateGeohashes(signer) ?: emptyList()) + + companion object { + const val KIND = 10081 + const val ALT = "Geohash List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + geohash: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = create( + geohashes = listOf(geohash), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun create( + geohashes: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent = + if (isPrivate) { + create( + publicGeohashes = emptyList(), + privateGeohashes = geohashes, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicGeohashes = geohashes, + privateGeohashes = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: GeohashListEvent, + geohash: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = add( + earlierVersion = earlierVersion, + geohashes = listOf(geohash), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: GeohashListEvent, + geohashes: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val geohashTags = geohashes.map { GeoHashTag.assembleSingle(it) } + return if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(geohashTags) + geohashTags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(geohashTags) + geohashTags, + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun remove( + earlierVersion: GeohashListEvent, + geohash: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(GeoHashTag.assembleSingle(geohash)), + tags = earlierVersion.tags.remove(GeoHashTag.assembleSingle(geohash)), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicGeohashes: List = emptyList(), + privateGeohashes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val template = build(publicGeohashes, privateGeohashes, signer, createdAt) + return signer.sign(template) + } + + fun create( + publicGeohashes: List = emptyList(), + privateGeohashes: List = emptyList(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val privateTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + val publicTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + AltTag.assemble(ALT) + return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) + } + + suspend fun build( + publicGeohashes: List = emptyList(), + privateGeohashes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + geohashes(publicGeohashes) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..b10d923486 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.geohashList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +fun TagArrayBuilder.followGeohash(geohash: String) = add(GeoHashTag.assembleSingle(geohash)) + +fun TagArrayBuilder.geohashes(geohash: List) = addAll(geohash.map { GeoHashTag.assembleSingle(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayExt.kt new file mode 100644 index 0000000000..efaeb4292e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.geohashList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +fun TagArray.geohashList() = mapNotNull(GeoHashTag::parse) + +fun TagArray.geohashSet() = mapNotNullTo(mutableSetOf(), GeoHashTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt new file mode 100644 index 0000000000..1fc7071206 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt @@ -0,0 +1,217 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.hashtagList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.map + +@Immutable +class HashtagListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) + + companion object { + const val KIND = 10015 + const val ALT = "Hashtag List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + hashtag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = create( + hashtags = listOf(hashtag), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun create( + hashtags: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent = + if (isPrivate) { + create( + publicHashtags = emptyList(), + privateHashtags = hashtags, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicHashtags = hashtags, + privateHashtags = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: HashtagListEvent, + hashtag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = add( + earlierVersion = earlierVersion, + hashtags = listOf(hashtag), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: HashtagListEvent, + hashtags: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val hashtags = hashtags.map { HashtagTag.assemble(it) } + return if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(hashtags) + hashtags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(hashtags) + hashtags, + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun remove( + earlierVersion: HashtagListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(HashtagTag.assemble(hashtag)), + tags = earlierVersion.tags.remove(HashtagTag.assemble(hashtag)), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicHashtags: List = emptyList(), + privateHashtags: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val template = build(publicHashtags, privateHashtags, signer, createdAt) + return signer.sign(template) + } + + fun create( + publicHashtags: List = emptyList(), + privateHashtags: List = emptyList(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val privateTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + val publicTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + AltTag.assemble(ALT) + return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) + } + + suspend fun build( + publicHashtags: List = emptyList(), + privateHashtags: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + hashtags(publicHashtags) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..e6168ce2fc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.hashtagList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag + +fun TagArrayBuilder.followHashTag(hashtag: String) = add(HashtagTag.assemble(hashtag)) + +fun TagArrayBuilder.hashtags(hashtags: List) = addAll(hashtags.map { HashtagTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt new file mode 100644 index 0000000000..7236e78713 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.hashtagList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag + +fun TagArray.hashtagList() = mapNotNull(HashtagTag::parse) + +fun TagArray.hashtagSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt new file mode 100644 index 0000000000..70055d4d93 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt @@ -0,0 +1,191 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class MuteListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + fun countMutes() = tags.count(MuteTag::isTagged) + + fun publicMutes(): List = tags.mapNotNull(MuteTag::parse) + + suspend fun privateMutes(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(MuteTag::parse) + + override fun dTag() = FIXED_D_TAG + + companion object { + const val KIND = 10000 + const val FIXED_D_TAG = "" + const val ALT = "Mute List" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:" + + suspend fun create( + mute: MuteTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent = + if (isPrivate) { + create( + publicMutes = emptyList(), + privateMutes = listOf(mute), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicMutes = listOf(mute), + privateMutes = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: MuteListEvent, + mute: MuteTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + publicTags = earlierVersion.tags, + privateTags = privateTags.plus(mute.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(mute.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: MuteListEvent, + mute: MuteTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + return resign( + privateTags = privateTags.remove(mute.toTagIdOnly()), + publicTags = earlierVersion.tags.remove(mute.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + publicTags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = publicTags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicMutes: List = emptyList(), + privateMutes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent { + val template = build(publicMutes, privateMutes, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicMutes: List = emptyList(), + privateMutes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateMutes.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + mutes(publicMutes) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4d0fdaa2fb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag + +fun TagArrayBuilder.mutes(mutes: List) = addAll(mutes.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt new file mode 100644 index 0000000000..1b918a68ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag + +fun TagArray.mutedUsersAndWords() = mapNotNull(MuteTag::parse) + +fun TagArray.mutedUsers() = mapNotNull(UserTag::parse) + +fun TagArray.mutedUserIds() = mapNotNull(UserTag::parseKey) + +fun TagArray.mutedUserIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) + +fun TagArray.mutedWords() = mapNotNull(WordTag::parse) + +fun TagArray.mutedWordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt new file mode 100644 index 0000000000..a6a65786c9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag + +sealed interface MuteTag { + fun toTagArray(): Tag + + fun toTagIdOnly(): Tag + + companion object { + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) + + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/UserTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/UserTag.kt new file mode 100644 index 0000000000..2558e6d542 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/UserTag.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class UserTag( + val pubKey: HexKey, + val relayHint: NormalizedRelayUrl? = null, +) : MuteTag { + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + pubKey.bytesUsedInMemory() + + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = pubKey.hexToByteArray().toNpub() + + override fun toTagArray() = assemble(pubKey, relayHint) + + override fun toTagIdOnly() = assemble(pubKey, null) + + companion object { + const val TAG_NAME = "p" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun isTagged( + tag: Array, + key: HexKey, + ): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Tag): UserTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return UserTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt new file mode 100644 index 0000000000..1204b5d422 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +class WordTag( + val word: String, +) : MuteTag { + fun countMemory(): Long = + 1 * pointerSizeInBytes + // 1 fields, 4 bytes each reference (32bit) + word.bytesUsedInMemory() + + override fun toTagArray() = assemble(word) + + override fun toTagIdOnly() = assemble(word) + + companion object { + const val TAG_NAME = "word" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + word: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == word + + @JvmStatic + fun parse(tag: Array): WordTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return WordTag(tag[1]) + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(word: String) = arrayOfNotNull(TAG_NAME, word) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt new file mode 100644 index 0000000000..a0c69898a2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.peopleList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID +import kotlin.collections.map +import kotlin.collections.plus + +@Immutable +class PeopleListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun nameOrTitle() = name() ?: title() + + @Deprecated("NIP-51 has deprecated Title. Use name instead", ReplaceWith("name()")) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun users() = tags.users() + + fun countMutes() = tags.count(MuteTag::isTagged) + + fun publicPeople(): List = tags.mapNotNull(MuteTag::parse) + + suspend fun privatePeople(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(MuteTag::parse) + + companion object { + const val KIND = 30000 + const val BLOCK_LIST_D_TAG = "mute" + const val ALT = "List of people" + + fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG) + + fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" + + suspend fun create( + name: String, + person: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent = + if (isPrivate) { + create( + name = name, + publicPeople = emptyList(), + privatePeople = listOf(person), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) + } else { + create( + name = name, + publicPeople = listOf(person), + privatePeople = emptyList(), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: PeopleListEvent, + person: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + publicTags = earlierVersion.tags, + privateTags = privateTags.plus(person.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(person.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: PeopleListEvent, + person: MuteTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + return resign( + privateTags = privateTags.remove(person.toTagIdOnly()), + publicTags = earlierVersion.tags.remove(person.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + publicTags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = publicTags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + name: String, + publicPeople: List = emptyList(), + privatePeople: List = emptyList(), + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + val template = build(name, publicPeople, privatePeople, signer, dTag, createdAt) + return signer.sign(template) + } + + suspend fun build( + name: String, + publicPeople: List = emptyList(), + privatePeople: List = emptyList(), + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privatePeople.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + peoples(publicPeople) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..2c9053ab2e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.peopleList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.peoples(peoples: List) = addAll(peoples.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt new file mode 100644 index 0000000000..67c4568165 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.peopleList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag + +fun TagArray.usersAndWords() = mapNotNull(MuteTag::parse) + +fun TagArray.users() = mapNotNull(UserTag::parse) + +fun TagArray.userIds() = mapNotNull(UserTag::parseKey) + +fun TagArray.userIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) + +fun TagArray.words() = mapNotNull(WordTag::parse) + +fun TagArray.wordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt new file mode 100644 index 0000000000..3798037bde --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.blockedRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BlockedRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun relays(signer: NostrSigner): List = publicRelays() + (privateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10006 + const val ALT = "Blocked relays from this author" + val ALT_TAG = arrayOf(AltTag.Companion.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: BlockedRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + blockedRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt new file mode 100644 index 0000000000..8ec9756107 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.broadcastRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class BroadcastRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10088 + const val ALT = "Broadcast relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: BroadcastRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BroadcastRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BroadcastRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): BroadcastRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + broadcastRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt new file mode 100644 index 0000000000..05b2327b67 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.indexerRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class IndexerRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10086 + const val ALT = "Indexer relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: IndexerRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): IndexerRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): IndexerRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): IndexerRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + indexerRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/ProxyRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/ProxyRelayListEvent.kt new file mode 100644 index 0000000000..7f48f3fc45 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/ProxyRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.proxyRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class ProxyRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10087 + const val ALT = "Proxy relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: ProxyRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ProxyRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ProxyRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ProxyRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + proxyRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt new file mode 100644 index 0000000000..7fd8262466 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.trustedRelays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class TrustedRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10089 + val ALT = "Trusted relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: TrustedRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + trustedRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt new file mode 100644 index 0000000000..b7c7261cb4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..a2ce226df0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import kotlin.collections.map + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.searchRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.blockedRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.trustedRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.broadcastRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.indexerRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.proxyRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.privateRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt new file mode 100644 index 0000000000..35413f65be --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.relays() = mapNotNull(RelayTag::parse) + +fun TagArray.relaySet() = mapNotNullTo(mutableSetOf(), RelayTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt new file mode 100644 index 0000000000..5866237eed --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relaySets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relaySet +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class RelaySetEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relays(): List = tags.mapNotNull(RelayTag.Companion::parse) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + companion object { + const val KIND = 30002 + const val ALT = "Relay list" + val ALT_TAG = arrayOf(AltTag.assemble(ALT)) + + suspend fun updateRelayList( + earlierVersion: RelaySetEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): RelaySetEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): RelaySetEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): RelaySetEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + relaySet(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt new file mode 100644 index 0000000000..50b700f645 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "r" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt index f3ebfc5cb2..5687d5fbf5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip51Lists.tags -@Deprecated("Use NameTag Instead") class TitleTag { companion object { const val TAG_NAME = "title" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt index 4418671c27..6e74d196f0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt @@ -50,13 +50,12 @@ class CalendarDateSlotEvent( const val KIND = 31922 const val ALT = "Full-day calendar event" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarDateSlotEvent) -> Unit, - ) { + ): CalendarDateSlotEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt index 72b7e9a154..9bec27678c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt @@ -40,13 +40,12 @@ class CalendarEvent( const val KIND = 31924 const val ALT = "Calendar" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarEvent) -> Unit, - ) { + ): CalendarEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt index 37ff201914..700f7a5663 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt @@ -52,13 +52,12 @@ class CalendarRSVPEvent( const val KIND = 31925 const val ALT = "Calendar event's invitation response" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarRSVPEvent) -> Unit, - ) { + ): CalendarRSVPEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt index 9f34f6c1de..2b9b5e2e37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt @@ -57,13 +57,12 @@ class CalendarTimeSlotEvent( const val KIND = 31923 const val ALT = "Calendar time-slot event" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarTimeSlotEvent) -> Unit, - ) { + ): CalendarTimeSlotEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt index e15892394c..1ecedc91ef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -28,6 +28,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag @@ -36,7 +39,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip37Drafts.ExposeInDraft +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -52,12 +62,55 @@ class LiveActivitiesChatMessageEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider, - ExposeInDraft { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + ExposeInDraft, + SearchableEvent { + override fun indexableContent() = content - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val eHints = tags.mapNotNull(ETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(ETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } private fun activityHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index e72556a15c..23c919fa6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -24,7 +24,13 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -47,7 +53,22 @@ class LiveActivitiesEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ParticipantTag::parseKey) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) @@ -60,7 +81,9 @@ class LiveActivitiesEvent( fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) - fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parse)) + fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) + + fun isLive() = status() == StatusTag.STATUS.LIVE fun currentParticipants() = tags.firstNotNullOfOrNull(CurrentParticipantsTag::parse) @@ -70,7 +93,7 @@ class LiveActivitiesEvent( fun relays() = tags.mapNotNull(RelayListTag::parse) - fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).map { it.relayUrls }.flatten() + fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() fun hasHost() = tags.any(ParticipantTag::isHost) @@ -78,9 +101,19 @@ class LiveActivitiesEvent( fun hosts() = tags.mapNotNull(ParticipantTag::parseHost) - fun checkStatus(eventStatus: String?): String? = - if (eventStatus == StatusTag.STATUS.LIVE.code && createdAt < TimeUtils.eightHoursAgo()) { - StatusTag.STATUS.ENDED.code + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = + if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { + StatusTag.STATUS.ENDED + } else if (eventStatus == StatusTag.STATUS.PLANNED) { + val starts = starts() + val ends = ends() + if (starts != null && starts < TimeUtils.oneHourAgo()) { + StatusTag.STATUS.ENDED + } else if (ends != null && ends < TimeUtils.oneHourAgo()) { + StatusTag.STATUS.ENDED + } else { + eventStatus + } } else { eventStatus } @@ -91,13 +124,12 @@ class LiveActivitiesEvent( const val KIND = 30311 const val ALT = "Live activity event" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (LiveActivitiesEvent) -> Unit, - ) { + ): LiveActivitiesEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt index 1c2733fbe1..5478fedf1b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt @@ -24,6 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -38,7 +41,7 @@ enum class ROLE( @Immutable data class ParticipantTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, val role: String?, val proof: String?, ) : PubKeyReferenceTag { @@ -64,7 +67,10 @@ data class ParticipantTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ParticipantTag(tag[1], tag.getOrNull(2), tag.getOrNull(3), tag.getOrNull(4)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ParticipantTag(tag[1], hint, tag.getOrNull(3), tag.getOrNull(4)) } @JvmStatic @@ -73,7 +79,10 @@ data class ParticipantTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[3] == ROLE.HOST.code) { return null } - return ParticipantTag(tag[1], tag[2], tag[3], tag.getOrNull(4)) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + return ParticipantTag(tag[1], hint, tag[3], tag.getOrNull(4)) } @JvmStatic @@ -84,6 +93,19 @@ data class ParticipantTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + @JvmStatic fun assemble( pubkey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt index ac25970480..e158c766f8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt @@ -21,30 +21,30 @@ package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure -class RelayListTag( - val relayUrls: List, -) { +class RelayListTag { companion object { const val TAG_NAME = "relays" @JvmStatic - fun parse(tag: Array): RelayListTag? { + fun parse(tag: Array): List? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val relays = tag.mapIndexedNotNull { index, s -> - if (index == 0) null else s + if (index == 0) null else RelayUrlNormalizer.normalizeOrNull(s) } - return RelayListTag(relays) + + if (relays.isEmpty()) return null + + return relays } @JvmStatic - fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.toTypedArray() - - @JvmStatic - fun assemble(tag: RelayListTag) = assemble(tag.relayUrls) + fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.map { it.url }.toTypedArray() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt index f788aa3186..37f65ba403 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt @@ -33,6 +33,16 @@ class StatusTag { ; fun toTagArray() = assemble(this) + + companion object { + fun parse(code: String): STATUS? = + when (code) { + LIVE.code -> LIVE + PLANNED.code -> PLANNED + ENDED.code -> ENDED + else -> null + } + } } companion object { @@ -46,6 +56,14 @@ class StatusTag { return tag[1] } + @JvmStatic + fun parseEnum(tag: Array): STATUS? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return STATUS.parse(tag[1]) + } + @JvmStatic fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.code) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index 057fd9b2d7..d0996a1fce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -23,13 +23,30 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,10 +58,62 @@ class WikiNoteEvent( content: String, sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - AddressableEvent { + AddressableEvent, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) @@ -68,21 +137,20 @@ class WikiNoteEvent( companion object { const val KIND = 30818 - fun create( + suspend fun create( msg: String, title: String?, replyTos: List?, mentions: List?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (WikiNoteEvent) -> Unit, - ) { + ): WikiNoteEvent { val tags = mutableListOf>() replyTos?.forEach { tags.add(arrayOf("e", it)) } mentions?.forEach { tags.add(arrayOf("p", it)) } title?.let { tags.add(arrayOf("title", it)) } tags.add(AltTag.assemble("Wiki Post: $title")) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt deleted file mode 100644 index 2a5a7507c1..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt +++ /dev/null @@ -1,436 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip55AndroidSigner - -import android.content.ContentResolver -import android.content.Intent -import android.util.Log -import android.util.LruCache -import androidx.core.net.toUri -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent - -enum class SignerType { - SIGN_EVENT, - NIP04_ENCRYPT, - NIP04_DECRYPT, - NIP44_ENCRYPT, - NIP44_DECRYPT, - GET_PUBLIC_KEY, - DECRYPT_ZAP_EVENT, - DERIVE_KEY, -} - -class Permission( - val type: String, - val kind: Int? = null, -) { - fun toJson(): String = "{\"type\":\"${type}\",\"kind\":$kind}" -} - -class Result( - @JsonProperty("package") val `package`: String?, - @JsonProperty("signature") val signature: String?, - @JsonProperty("result") val result: String?, - @JsonProperty("id") val id: String?, -) { - companion object { - val mapper: ObjectMapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule( - SimpleModule().addDeserializer(Result::class.java, ResultDeserializer()), - ) - - private class ResultDeserializer : StdDeserializer(Result::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Result { - val jsonObject: JsonNode = jp.codec.readTree(jp) - return Result( - jsonObject.get("package").asText().intern(), - jsonObject.get("signature")?.asText()?.intern(), - jsonObject.get("result")?.asText()?.intern(), - jsonObject.get("id").asText().intern(), - ) - } - } - - fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) - - /** - * Parses the json with a string of events to an Array of Event objects. - */ - fun fromJsonArray(json: String): Array = mapper.readValue(json) - } -} - -class ExternalSignerLauncher( - private val currentUserPubKeyHex: String, - val signerPackageName: String, -) { - private val contentCache = LruCache Unit>(50) - - private var signerAppLauncher: ((Intent) -> Unit)? = null - private var contentResolver: (() -> ContentResolver)? = null - - /** Call this function when the launcher becomes available on activity, fragment or compose */ - fun registerLauncher( - launcher: ((Intent) -> Unit), - contentResolver: (() -> ContentResolver), - ) { - this.signerAppLauncher = launcher - this.contentResolver = contentResolver - } - - /** Call this function when the activity is destroyed or is about to be replaced. */ - fun clearLauncher() { - this.signerAppLauncher = null - this.contentResolver = null - } - - fun newResult(data: Intent) { - val results = data.getStringExtra("results") - if (results != null) { - val localResults: Array = Result.fromJsonArray(results) - localResults.forEach { - val signature = it.result ?: it.signature ?: "" - val packageName = it.`package`?.let { "-$it" } ?: "" - val id = it.id ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } else { - val signature = data.getStringExtra("result") ?: data.getStringExtra("signature") ?: "" - val packageName = data.getStringExtra("package")?.let { "-$it" } ?: "" - val id = data.getStringExtra("id") ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } - - fun openSignerApp( - data: String, - type: SignerType, - pubKey: HexKey, - id: String, - onReady: (String) -> Unit, - ) { - signerAppLauncher?.let { - openSignerApp( - data, - type, - it, - pubKey, - id, - onReady, - ) - } - } - - private fun defaultPermissions(): String { - val permissions = - listOf( - Permission( - "sign_event", - 22242, - ), - Permission( - "sign_event", - 31234, - ), - Permission( - "nip04_encrypt", - ), - Permission( - "nip04_decrypt", - ), - Permission( - "nip44_encrypt", - ), - Permission( - "nip44_decrypt", - ), - Permission( - "decrypt_zap_event", - ), - ) - val jsonArray = StringBuilder("[") - permissions.forEachIndexed { index, permission -> - jsonArray.append(permission.toJson()) - if (index < permissions.size - 1) { - jsonArray.append(",") - } - } - jsonArray.append("]") - - return jsonArray.toString() - } - - private fun openSignerApp( - data: String, - type: SignerType, - intentLauncher: (Intent) -> Unit, - pubKey: HexKey, - id: String, - onReady: (String) -> Unit, - ) { - val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$data".toUri()) - val signerType = - when (type) { - SignerType.SIGN_EVENT -> "sign_event" - SignerType.NIP04_ENCRYPT -> "nip04_encrypt" - SignerType.NIP04_DECRYPT -> "nip04_decrypt" - SignerType.NIP44_ENCRYPT -> "nip44_encrypt" - SignerType.NIP44_DECRYPT -> "nip44_decrypt" - SignerType.GET_PUBLIC_KEY -> "get_public_key" - SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event" - SignerType.DERIVE_KEY -> "derive_key" - } - intent.putExtra("type", signerType) - intent.putExtra("pubKey", pubKey) - intent.putExtra("id", id) - if (type !== SignerType.GET_PUBLIC_KEY) { - intent.putExtra("current_user", currentUserPubKeyHex) - } else { - intent.putExtra("permissions", defaultPermissions()) - } - if (signerPackageName.isNotBlank()) { - intent.`package` = signerPackageName - } - - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - - contentCache.put(id, onReady) - - intentLauncher(intent) - } - - fun openSigner( - event: Event, - onReady: (String) -> Unit, - ) { - getDataFromResolver( - SignerType.SIGN_EVENT, - arrayOf(event.toJson(), event.pubKey), - ).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - event.toJson(), - SignerType.SIGN_EVENT, - "", - event.id, - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - private fun getDataFromResolver( - signerType: SignerType, - data: Array, - ): kotlin.Result = getDataFromResolver(signerType, data, contentResolver) - - private fun getDataFromResolver( - signerType: SignerType, - data: Array, - contentResolver: (() -> ContentResolver)? = null, - ): kotlin.Result { - val localData = - if (signerType !== SignerType.GET_PUBLIC_KEY) { - arrayOf(*data, currentUserPubKeyHex) - } else { - data - } - - try { - contentResolver - ?.let { it() } - ?.query( - "content://$signerPackageName.$signerType".toUri(), - localData, - "1", - null, - null, - ).use { - if (it == null) { - return kotlin.Result.success(null) - } - if (it.moveToFirst()) { - if (it.getColumnIndex("rejected") > -1) { - Log.d("getDataFromResolver", "Permission denied") - return kotlin.Result.failure(Exception("Permission denied")) - } - var index = it.getColumnIndex("result") - if (index < 0) { - index = it.getColumnIndex("signature") - } - if (index < 0) { - Log.d("getDataFromResolver", "column 'signature' not found") - return kotlin.Result.success(null) - } - return kotlin.Result.success(it.getString(index)) - } - } - } catch (e: Exception) { - Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e) - return kotlin.Result.success(null) - } - - return kotlin.Result.success(null) - } - - fun hashCodeFields( - str1: String, - onReady: (String) -> Unit, - ): Int { - var result = str1.hashCode() - result = 31 * result + onReady.hashCode() - return result - } - - fun hashCodeFields( - str1: String, - str2: String, - onReady: (String) -> Unit, - ): Int { - var result = str1.hashCode() - result = 31 * result + str2.hashCode() - result = 31 * result + onReady.hashCode() - return result - } - - fun decrypt( - encryptedContent: String, - pubKey: HexKey, - signerType: SignerType = SignerType.NIP04_DECRYPT, - onReady: (String) -> Unit, - ) { - getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - encryptedContent, - signerType, - pubKey, - hashCodeFields(encryptedContent, pubKey, onReady).toString(), - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - fun encrypt( - decryptedContent: String, - pubKey: HexKey, - signerType: SignerType = SignerType.NIP04_ENCRYPT, - onReady: (String) -> Unit, - ) { - getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - decryptedContent, - signerType, - pubKey, - hashCodeFields(decryptedContent, pubKey, onReady).toString(), - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (String) -> Unit, - ) { - getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - event.toJson(), - SignerType.DECRYPT_ZAP_EVENT, - event.pubKey, - event.id, - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - fun deriveKey( - nonce: HexKey, - signerType: SignerType = SignerType.DERIVE_KEY, - onReady: (String) -> Unit, - ) { - getDataFromResolver(signerType, arrayOf(nonce)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - nonce, - signerType, - "", - hashCodeFields(nonce, onReady).toString(), - onReady, - ) - } else { - onReady(it) - } - }, - ) - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt deleted file mode 100644 index e294e32f87..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip55AndroidSigner - -import android.util.Log -import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent - -class NostrSignerExternal( - pubKey: HexKey, - val launcher: ExternalSignerLauncher, -) : NostrSigner(pubKey) { - override fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - val event = - Event( - id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) - - launcher.openSigner(event) { signature -> - if (signature.startsWith("{")) { - val localEvent = Event.fromJson(signature) - ( - EventFactory.create( - localEvent.id, - localEvent.pubKey, - localEvent.createdAt, - localEvent.kind, - localEvent.tags, - localEvent.content, - localEvent.sig, - ) as? T? - )?.let { onReady(it) } - } else { - ( - EventFactory.create( - event.id, - event.pubKey, - event.createdAt, - event.kind, - event.tags, - event.content, - signature.split("-")[0], - ) as? T? - )?.let { onReady(it) } - } - } - } - - override fun nip04Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP04_ENCRYPT, - onReady, - ) - } - - override fun nip04Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP04_DECRYPT, - onReady, - ) - } - - override fun nip44Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP44_ENCRYPT, - onReady, - ) - } - - override fun nip44Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP44_DECRYPT, - onReady, - ) - } - - override fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) { - launcher.deriveKey( - nonce, - SignerType.DERIVE_KEY, - onReady, - ) - } - - override fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - launcher.decryptZapEvent(event) { jsonEvent -> - try { - (Event.fromJson(jsonEvent) as? LnZapPrivateEvent)?.let { onReady(it) } - } catch (e: Exception) { - Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $jsonEvent") - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt new file mode 100644 index 0000000000..4aadd4a8ad --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api + +enum class CommandType( + val code: String, +) { + SIGN_EVENT("sign_event"), + NIP04_ENCRYPT("nip04_encrypt"), + NIP04_DECRYPT("nip04_decrypt"), + NIP44_ENCRYPT("nip44_encrypt"), + NIP44_DECRYPT("nip44_decrypt"), + GET_PUBLIC_KEY("get_public_key"), + DECRYPT_ZAP_EVENT("decrypt_zap_event"), + DERIVE_KEY("derive_key"), + ; + + companion object { + fun parse(code: String): CommandType? = + when (code) { + SIGN_EVENT.code -> SIGN_EVENT + NIP04_ENCRYPT.code -> NIP04_ENCRYPT + NIP04_DECRYPT.code -> NIP04_DECRYPT + NIP44_ENCRYPT.code -> NIP44_ENCRYPT + NIP44_DECRYPT.code -> NIP44_DECRYPT + GET_PUBLIC_KEY.code -> GET_PUBLIC_KEY + DECRYPT_ZAP_EVENT.code -> DECRYPT_ZAP_EVENT + DERIVE_KEY.code -> DERIVE_KEY + else -> null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt new file mode 100644 index 0000000000..859c52b35f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent + +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful( + val result: T, + ) : RequestAddressed + + class ManuallyRejected : RequestAddressed + + class AutomaticallyRejected : RequestAddressed + + class TimedOut : RequestAddressed + + class NoActivityToLaunchFrom : RequestAddressed + + class SignerNotFound : RequestAddressed + + class ReceivedButCouldNotPerform( + val message: String? = null, + ) : RequestAddressed + + class ReceivedButCouldNotParseEventFromResult( + val eventJson: String, + ) : RequestAddressed + + class ReceivedButCouldNotVerifyResultingEvent( + val invalidEvent: Event, + ) : RequestAddressed + } + + sealed interface RequestIncomplete : SignerResult { + class RequiresManualApproval : RequestIncomplete + + class ErrorExceptionCallingContentResolver( + val e: Exception? = null, + ) : SignerResult, + RequestIncomplete + } +} + +interface IResult + +data class PubKeyResult( + val pubkey: HexKey, + val packageName: String, +) : IResult + +data class SignResult( + val event: Event, +) : IResult + +data class EncryptionResult( + val ciphertext: String, +) : IResult + +data class DecryptionResult( + val plaintext: String, +) : IResult + +data class ZapEventDecryptionResult( + val privateEvent: LnZapPrivateEvent, +) : IResult + +data class DerivationResult( + val newPrivKey: HexKey, +) : IResult diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt new file mode 100644 index 0000000000..9e496471ea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent + +class DecryptZapQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + fun query(event: Event): SignerResult = + contentResolver.query( + "content://$packageName.${CommandType.DECRYPT_ZAP_EVENT}".toUri(), + arrayOf(event.toJson(), event.pubKey, loggedInUser), + ) { cursor -> + val decryptedEventAsJson = cursor.getStringByName("result") + if (!decryptedEventAsJson.isNullOrBlank()) { + if (decryptedEventAsJson.startsWith("{")) { + val event = Event.fromJsonOrNull(decryptedEventAsJson) as? LnZapPrivateEvent + if (event != null) { + if (event.verify()) { + SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(decryptedEventAsJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(decryptedEventAsJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(decryptedEventAsJson) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DeriveKeyQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DeriveKeyQuery.kt new file mode 100644 index 0000000000..e2fe9a4606 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DeriveKeyQuery.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class DeriveKeyQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.DERIVE_KEY}".toUri() + + fun query(nonce: HexKey): SignerResult = + contentResolver.query( + uri, + arrayOf(nonce, loggedInUser), + ) { cursor -> + val newPrivateKey = cursor.getStringByName("result") + if (!newPrivateKey.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(DerivationResult(newPrivateKey)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/LoginQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/LoginQuery.kt new file mode 100644 index 0000000000..b86099b2ba --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/LoginQuery.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class LoginQuery( + val packageName: String, + val contentResolver: ContentResolver, +) { + companion object { + val LOGIN = arrayOf("login") + } + + val uri = "content://$packageName.${CommandType.GET_PUBLIC_KEY}".toUri() + + fun query(): SignerResult = + contentResolver.query( + uri, + LOGIN, + ) { cursor -> + val pubkeyHex = cursor.getStringByName("result") + if (!pubkeyHex.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(PubKeyResult(pubkeyHex, packageName)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04DecryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04DecryptQuery.kt new file mode 100644 index 0000000000..ed7b13d080 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04DecryptQuery.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip04DecryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP04_DECRYPT}".toUri() + + fun query( + ciphertext: String, + fromPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(ciphertext, fromPubKey, loggedInUser), + ) { cursor -> + val plaintext = cursor.getStringByName("result") + if (!plaintext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04EncryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04EncryptQuery.kt new file mode 100644 index 0000000000..a318019f71 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04EncryptQuery.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip04EncryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP04_ENCRYPT}".toUri() + + fun query( + plaintext: String, + toPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(plaintext, toPubKey, loggedInUser), + ) { cursor -> + val ciphertext = cursor.getStringByName("result") + if (!ciphertext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44DecryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44DecryptQuery.kt new file mode 100644 index 0000000000..4d9288f74c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44DecryptQuery.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip44DecryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP44_DECRYPT}".toUri() + + fun query( + ciphertext: String, + fromPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(ciphertext, fromPubKey, loggedInUser), + ) { cursor -> + val plaintext = cursor.getStringByName("result") + if (!plaintext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44EncryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44EncryptQuery.kt new file mode 100644 index 0000000000..6e074344f5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44EncryptQuery.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip44EncryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP44_ENCRYPT}".toUri() + + fun query( + plaintext: String, + toPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(plaintext, toPubKey, loggedInUser), + ) { cursor -> + val ciphertext = cursor.getStringByName("result") + if (!ciphertext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignQuery.kt new file mode 100644 index 0000000000..bf1bc9924b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignQuery.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class SignQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.SIGN_EVENT}".toUri() + + fun query(unsignedEvent: Event): SignerResult = + contentResolver.query( + uri, + arrayOf(unsignedEvent.toJson(), unsignedEvent.pubKey, loggedInUser), + ) { cursor -> + val eventJson = cursor.getStringByName("event") + if (!eventJson.isNullOrBlank()) { + if (eventJson.startsWith("{")) { + val event = Event.fromJsonOrNull(eventJson) + if (event != null) { + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + val signature = cursor.getStringByName("result") + if (!signature.isNullOrBlank()) { + val event: Event = + EventFactory.create( + id = unsignedEvent.id, + pubKey = unsignedEvent.pubKey, + createdAt = unsignedEvent.createdAt, + kind = unsignedEvent.kind, + tags = unsignedEvent.tags, + content = unsignedEvent.content, + sig = signature, + ) + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt new file mode 100644 index 0000000000..ae940905b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.util.Log +import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult + +fun ContentResolver.query( + uri: Uri, + projection: Array, + map: (cursor: Cursor) -> SignerResult, +): SignerResult = + try { + query( + uri, + projection, + null, + null, + null, + ).use { + if (it != null && it.moveToFirst()) { + if (it.getColumnIndex("rejected") > -1) { + SignerResult.RequestAddressed.AutomaticallyRejected() + } else { + map(it) + } + } else { + SignerResult.RequestIncomplete.RequiresManualApproval() + } + } + } catch (e: Exception) { + Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e) + SignerResult.RequestIncomplete.ErrorExceptionCallingContentResolver(e) + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/CursorExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/CursorExt.kt new file mode 100644 index 0000000000..ca81cc000f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/CursorExt.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils + +import android.database.Cursor + +fun Cursor.getStringByName(name: String): String? { + val index = getColumnIndex(name) + return if (index >= 0) { + val result = getString(index) + result.ifBlank { null } + } else { + null + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt new file mode 100644 index 0000000000..8fc522961b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.util.Log +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.collections.forEach +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +/** + * This class manages the lifecycle of foreground signing requests in a NIP-55 compliant Android signer flow. + * + * - It tracks pending signing requests using a unique ID and allows for awaiting their results via coroutines. + * - Provides a way to launch foreground Intents (to request user approval) and wait for the response. + * - Handles timeouts on user approval using [tryAndWait]. + * - Collects results via [newResponse] when the user responds to the foreground request. + * + * Main components: + * + * - `awaitingRequests`: LRU cache mapping request IDs to continuations for async response handling. + * - `appLauncher`: Function reference to launch foreground Intents (typically provided by an Activity). + * - `launchAndWait`: Suspend function to send an Intent, wait for an answer, and parse the result. + * - `newResponse`: Handles incoming results from the foreground activity using a unique ID. + * + * Key usage flows: + * + * - Request initiated -> store continuation by ID -> launch intent -> wait + * - User responds -> resume continuation -> remove ID -> return parsed result + * + * The class also cleans up pending requests on timeout or cancellation. + */ +class IntentRequestManager( + val foregroundApprovalTimeout: Long = 30000, +) { + val activityNotFoundIntent = Intent() + + // LRU cache to store pending requests and their continuations. + private val awaitingRequests = LruCache>(2000) + + // Function to launch an Intent in the foreground. + private var appLauncher: ((Intent) -> Unit)? = null + + /** Call this function when the launcher becomes available on activity, fragment or compose */ + fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) { + this.appLauncher = launcher + } + + /** Call this function when the activity is destroyed or is about to be replaced. */ + fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) { + if (this.appLauncher == launcher) { + this.appLauncher = null + } + } + + fun newResponse(data: Intent) { + val results = data.getStringExtra("results") + if (results != null) { + // This happens when the intent responds to many requests at the same time. + IntentResult.fromJsonArray(results).forEach { result -> + if (result.id != null) { + awaitingRequests[result.id]?.resume(result) + awaitingRequests.remove(result.id) + } + } + } else { + val result = IntentResult.fromIntent(data) + if (result.id != null) { + awaitingRequests[result.id]?.resume(result) + awaitingRequests.remove(result.id) + } + } + } + + fun hasForegroundActivity() = appLauncher != null + + /** + * Launches the signer, waits and parses the result + * + * @param requestIntent The Intent to be launched. + * @param parser A function that parses the response Intent into a [SignerResult.RequestAddressed]. + * @return The result after parsing the Intent using the provided parser. + * + * This function uses the [tryAndWait] utility to implement a timeout on the foreground approval. + * It assigns a unique ID to the request and keeps a continuation to resume once the result is received. + * If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests]. + * Flags are added to the Intent to ensure it is brought to the front if already running. + */ + suspend fun launchWaitAndParse( + requestIntentBuilder: () -> Intent, + parser: (intent: IntentResult) -> SignerResult.RequestAddressed, + ): SignerResult.RequestAddressed = + appLauncher?.let { launcher -> + val requestIntent = requestIntentBuilder() + val callId = RandomInstance.randomChars(32) + + requestIntent.putExtra("id", callId) + requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + + try { + val resultIntent = + tryAndWait(foregroundApprovalTimeout) { continuation -> + continuation.invokeOnCancellation { + awaitingRequests.remove(callId) + } + + awaitingRequests.put(callId, continuation) + + try { + launcher.invoke(requestIntent) + } catch (e: Exception) { + Log.e("ExternalSigner", "Error launching intent", e) + awaitingRequests.remove(callId) + throw e + } + } + + when (resultIntent) { + null -> SignerResult.RequestAddressed.TimedOut() + else -> parser(resultIntent) + } + } catch (e: ActivityNotFoundException) { + Log.e("ExternalSigner", "Error launching intent: Signer not found", e) + SignerResult.RequestAddressed.SignerNotFound() + } + } ?: SignerResult.RequestAddressed.NoActivityToLaunchFrom() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt new file mode 100644 index 0000000000..80ee7602be --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +class DecryptZapRequest { + companion object { + fun assemble( + event: LnZapRequestEvent, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.toJson()}".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP44_DECRYPT.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DeriveKeyRequest.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DeriveKeyRequest.kt index e1bbd9e1d0..dac0401635 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DeriveKeyRequest.kt @@ -18,23 +18,25 @@ * 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.relays.recommendations +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests -import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import android.content.Intent +import androidx.core.net.toUri import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType -@Immutable -data class Kind3RelayProposalSetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, - val relayStat: RelayStat, - val paidRelay: Boolean = false, - val users: List, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) +class DeriveKeyRequest { + companion object { + fun assemble( + nonce: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$nonce".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.DERIVE_KEY.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt new file mode 100644 index 0000000000..39a02fa9c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission + +class LoginRequest { + companion object { + val DefaultPermissions = + listOf( + Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND), + Permission(CommandType.NIP04_ENCRYPT), + Permission(CommandType.NIP04_DECRYPT), + Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.DECRYPT_ZAP_EVENT), + ) + + fun assemble(permissions: List = DefaultPermissions): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()) + intent.putExtra("type", CommandType.GET_PUBLIC_KEY.code) + intent.putExtra("permissions", JsonMapper.mapper.writeValueAsString(permissions)) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04DecryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04DecryptRequest.kt new file mode 100644 index 0000000000..a2e02e27f8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04DecryptRequest.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip04DecryptRequest { + companion object { + fun assemble( + ciphertext: String, + fromPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$ciphertext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP04_DECRYPT.code) + intent.putExtra("pubKey", fromPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04EncryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04EncryptRequest.kt new file mode 100644 index 0000000000..473cbf5f4b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04EncryptRequest.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip04EncryptRequest { + companion object { + fun assemble( + plaintext: String, + toPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$plaintext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP04_ENCRYPT.code) + intent.putExtra("pubKey", toPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44DecryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44DecryptRequest.kt new file mode 100644 index 0000000000..0cd72d53a1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44DecryptRequest.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip44DecryptRequest { + companion object { + fun assemble( + ciphertext: String, + fromPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$ciphertext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP44_DECRYPT.code) + intent.putExtra("pubKey", fromPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44EncryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44EncryptRequest.kt new file mode 100644 index 0000000000..4e19ffcb2f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44EncryptRequest.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip44EncryptRequest { + companion object { + fun assemble( + plaintext: String, + toPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$plaintext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP44_ENCRYPT.code) + intent.putExtra("pubKey", toPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignRequest.kt new file mode 100644 index 0000000000..55136388dc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignRequest.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class SignRequest { + companion object { + fun assemble( + event: Event, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.toJson()}".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.SIGN_EVENT.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt new file mode 100644 index 0000000000..35b3474a2c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent + +class DecryptZapResponse { + companion object { + fun assemble(event: LnZapPrivateEvent): IntentResult = + IntentResult( + result = event.toJson(), + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val eventJson = intent.result + return if (!eventJson.isNullOrBlank()) { + if (eventJson.startsWith("{")) { + val event = Event.fromJsonOrNull(eventJson) as? LnZapPrivateEvent + if (event != null) { + SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt new file mode 100644 index 0000000000..3d51fb9a5c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class DeriveKeyResponse { + companion object { + fun assemble(newPrivateKey: HexKey): IntentResult = + IntentResult( + result = newPrivateKey, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val newPrivateKey = intent.result + return if (newPrivateKey != null) { + SignerResult.RequestAddressed.Successful(DerivationResult(newPrivateKey)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterUserMetadataByFollows.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterUserMetadataByFollows.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt index b9f3b5c3ab..ce52787213 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterUserMetadataByFollows.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt @@ -18,35 +18,38 @@ * 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.home.datasource.nip65Follows +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult -fun filterUserMetadataByFollows( - follows: Map>?, - since: Map?, -): List { - if (follows != null && follows.isEmpty()) return emptyList() +class LoginResponse { + companion object { + fun assemble( + pubkey: HexKey, + packageName: String, + ): IntentResult = + IntentResult( + result = pubkey, + `package` = packageName, + ) - return listOf( - TypedFilter( - types = setOf(if (follows != null) FeedType.FOLLOWS else FeedType.GLOBAL), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ), - authors = follows, - since = since, - ), - ), - ) + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val pubkey = intent.result + val packageName = intent.`package` + + return if (pubkey != null && packageName != null) { + SignerResult.RequestAddressed.Successful( + PubKeyResult( + pubkey = pubkey, + packageName = packageName, + ), + ) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt new file mode 100644 index 0000000000..84cc20ab66 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip04DecryptResponse { + companion object { + fun assemble(plaintext: String): IntentResult = + IntentResult( + result = plaintext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val plaintext = intent.result + return if (plaintext != null) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt new file mode 100644 index 0000000000..969520fef0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip04EncryptResponse { + companion object { + fun assemble(ciphertext: String): IntentResult = + IntentResult( + result = ciphertext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val ciphertext = intent.result + return if (ciphertext != null) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt new file mode 100644 index 0000000000..e65dd8f4f3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip44DecryptResponse { + companion object { + fun assemble(plaintext: String): IntentResult = + IntentResult( + result = plaintext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val plaintext = intent.result + return if (plaintext != null) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt new file mode 100644 index 0000000000..243856a389 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip44EncryptResponse { + companion object { + fun assemble(ciphertext: String): IntentResult = + IntentResult( + result = ciphertext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val ciphertext = intent.result + return if (ciphertext != null) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt new file mode 100644 index 0000000000..6e8a6d639e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class SignResponse { + companion object { + fun assemble(event: Event): IntentResult = + IntentResult( + event = event.toJson(), + result = event.sig, + ) + + fun parse( + intent: IntentResult, + unsignedEvent: Event, + ): SignerResult.RequestAddressed { + val eventJson = intent.event + return if (eventJson != null) { + if (eventJson.startsWith("{")) { + val event = Event.fromJsonOrNull(eventJson) + if (event != null) { + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(eventJson) + } + } else { + val signature = intent.result + if (signature != null && signature.length == 128) { + val event: Event = + EventFactory.create( + id = unsignedEvent.id, + pubKey = unsignedEvent.pubKey, + createdAt = unsignedEvent.createdAt, + kind = unsignedEvent.kind, + tags = unsignedEvent.tags, + content = unsignedEvent.content, + sig = signature, + ) + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(signature) + } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt new file mode 100644 index 0000000000..b1beca7a87 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results + +import android.content.Intent +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper + +data class IntentResult( + val `package`: String? = null, + val result: String? = null, + val event: String? = null, + val id: String? = null, +) { + fun toJson(): String = JsonMapper.mapper.writeValueAsString(this) + + fun toIntent(): Intent { + val intent = Intent() + intent.putExtra("id", id) + intent.putExtra("result", result) + intent.putExtra("event", event) + intent.putExtra("package", `package`) + return intent + } + + companion object { + fun fromIntent(data: Intent): IntentResult = + IntentResult( + id = data.getStringExtra("id"), + result = data.getStringExtra("result"), + event = data.getStringExtra("event"), + `package` = data.getStringExtra("package"), + ) + + fun fromJson(json: String): IntentResult = JsonMapper.mapper.readValue(json) + + fun fromJsonArray(json: String): List = JsonMapper.mapper.readValue>(json) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt new file mode 100644 index 0000000000..e53cb8be22 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer + +class IntentResultJsonDeserializer : StdDeserializer(IntentResult::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): IntentResult { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return IntentResult( + `package` = jsonObject.get("package")?.asText()?.intern(), + result = jsonObject.get("result")?.asText()?.intern(), + event = jsonObject.get("event")?.asText()?.intern(), + id = jsonObject.get("id")?.asText()?.intern(), + ) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt similarity index 58% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt index 404a8536f4..289f4daed0 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt @@ -18,28 +18,23 @@ * 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.ammolite.relays.datasources +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results -class SubscriptionSet { - private var subscriptions = mapOf() +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer - fun contains(subId: String) = subscriptions.containsKey(subId) - - fun add(sub: Subscription) { - subscriptions = subscriptions + Pair(sub.id, sub) +class IntentResultJsonSerializer : StdSerializer(IntentResult::class.java) { + override fun serialize( + result: IntentResult, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + result.`package`?.let { gen.writeStringField("package", it) } + result.result?.let { gen.writeStringField("result", it) } + result.event?.let { gen.writeStringField("event", it) } + result.id?.let { gen.writeStringField("id", it) } + gen.writeEndObject() } - - fun remove(subId: String) { - subscriptions = subscriptions.minus(subId) - } - - fun remove(sub: Subscription) = remove(sub.id) - - fun newSub(onEOSE: ((Long, String) -> Unit)? = null): Subscription = Subscription(onEose = onEOSE).also { add(it) } - - fun forEach(action: (Subscription) -> Unit) = subscriptions.values.forEach(action) - - operator fun get(subId: String) = subscriptions[subId] - - fun actives() = subscriptions.values.filter { it.typedFilters != null } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt new file mode 100644 index 0000000000..9c1511b532 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission + +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Permission( + val type: CommandType?, + val kind: Int? = null, +) { + fun toJson(): String = JsonMapper.mapper.writeValueAsString(this) + + companion object { + fun fromJson(json: String): Permission = JsonMapper.mapper.readValue(json, Permission::class.java) + + fun fromJsonArray(json: String): Array = JsonMapper.mapper.readValue(json, Array::class.java) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionDeserializer.kt new file mode 100644 index 0000000000..ebea3de4b1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionDeserializer.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class PermissionDeserializer : StdDeserializer(Permission::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Permission { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return Permission( + type = CommandType.parse(jsonObject.get("type").asText()), + kind = jsonObject.get("kind")?.asInt(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionSerializer.kt new file mode 100644 index 0000000000..3d53505314 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionSerializer.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +class PermissionSerializer : StdSerializer(Permission::class.java) { + override fun serialize( + permission: Permission, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + permission.type.let { gen.writeStringField("type", it?.code) } + if (permission.kind != null) { + gen.writeNumberField("kind", permission.kind) + } else { + gen.writeNullField("kind") + } + gen.writeEndObject() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt new file mode 100644 index 0000000000..015c3fc65f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client + +import android.content.Intent +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.LoginRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.LoginResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission + +object ExternalSignerLogin { + fun createIntent(permissions: List = LoginRequest.DefaultPermissions): Intent { + val intent = LoginRequest.assemble(permissions) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + return intent + } + + fun parseResult(data: Intent): SignerResult { + val result = IntentResult.fromIntent(data) + return LoginResponse.parse(result) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt new file mode 100644 index 0000000000..5ac3350a8e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client + +import android.content.Intent + +interface IActivityLauncher { + fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) + + fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) + + fun newResponse(data: Intent) + + fun hasForegroundActivity(): Boolean +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt new file mode 100644 index 0000000000..92d7f5e48c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri + +@SuppressLint("QueryPermissionsNeeded") +fun isExternalSignerInstalled(context: Context): Boolean = + context.packageManager + .queryIntentActivities( + Intent().apply { + action = Intent.ACTION_VIEW + data = "nostrsigner:".toUri() + }, + 0, + ).isNotEmpty() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt new file mode 100644 index 0000000000..40cf1749c4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client + +import android.content.ContentResolver +import android.content.Intent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.BackgroundRequestHandler +import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.ForegroundRequestHandler +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import java.lang.Exception + +class NostrSignerExternal( + pubKey: HexKey, + packageName: String, + contentResolver: ContentResolver, +) : NostrSigner(pubKey), + IActivityLauncher { + override fun isWriteable(): Boolean = true + + val backgroundQuery = BackgroundRequestHandler(pubKey, packageName, contentResolver) + val foregroundQuery = ForegroundRequestHandler(pubKey, packageName) + + override fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) { + this.foregroundQuery.launcher.registerForegroundLauncher(launcher) + } + + override fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) { + this.foregroundQuery.launcher.unregisterForegroundLauncher(launcher) + } + + override fun newResponse(data: Intent) { + this.foregroundQuery.launcher.newResponse(data) + } + + override fun hasForegroundActivity() = this.foregroundQuery.launcher.hasForegroundActivity() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + val unsignedEvent = + Event( + id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) + + val result = backgroundQuery.sign(unsignedEvent) ?: foregroundQuery.sign(unsignedEvent) + + if (result is SignerResult.RequestAddressed.Successful) { + (result.result.event as? T)?.let { + return it + } + } + + throw convertExceptions("Could not sign", result) + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + if (plaintext.isBlank()) return "" + + val result = backgroundQuery.nip04Encrypt(plaintext, toPublicKey) ?: foregroundQuery.nip04Encrypt(plaintext, toPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.ciphertext + } + + throw convertExceptions("Could not encrypt", result) + } + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() + + val result = backgroundQuery.nip04Decrypt(ciphertext, fromPublicKey) ?: foregroundQuery.nip04Decrypt(ciphertext, fromPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.plaintext + } + + throw convertExceptions("Could not decrypt", result) + } + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + if (plaintext.isBlank()) return "" + + val result = backgroundQuery.nip44Encrypt(plaintext, toPublicKey) ?: foregroundQuery.nip44Encrypt(plaintext, toPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.ciphertext + } + + throw convertExceptions("Could not encrypt", result) + } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() + + val result = backgroundQuery.nip44Decrypt(ciphertext, fromPublicKey) ?: foregroundQuery.nip44Decrypt(ciphertext, fromPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.plaintext + } + + throw convertExceptions("Could not decrypt", result) + } + + override suspend fun deriveKey(nonce: HexKey): HexKey { + val result = backgroundQuery.deriveKey(nonce) ?: foregroundQuery.deriveKey(nonce) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.newPrivKey + } + + throw convertExceptions("Could not derive key", result) + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt() + + val result = backgroundQuery.decryptZapEvent(event) ?: foregroundQuery.decryptZapEvent(event) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.privateEvent + } + + throw convertExceptions("Could not decrypt private zap", result) + } + + fun convertExceptions( + title: String, + result: SignerResult.RequestAddressed<*>, + ): Exception = + when (result) { + is SignerResult.RequestAddressed.Successful<*> -> IllegalStateException("$title: This should not happen. There is a bug on Quartz.") + is SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<*> -> IllegalStateException("$title: Failed to parse event: ${result.eventJson}.") + is SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<*> -> IllegalStateException("$title: Failed to verify event: ${result.invalidEvent.toJson()}.") + + is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> SignerExceptions.CouldNotPerformException("$title: ${result.message}") + is SignerResult.RequestAddressed.SignerNotFound<*> -> SignerExceptions.SignerNotFoundException("$title: Signer app was not found.") + + is SignerResult.RequestAddressed.AutomaticallyRejected<*> -> SignerExceptions.AutomaticallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.ManuallyRejected<*> -> SignerExceptions.ManuallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.TimedOut<*> -> SignerExceptions.TimedOutException("$title: User didn't accept or reject in time.") + is SignerResult.RequestAddressed.NoActivityToLaunchFrom<*> -> SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException("$title: No activity to launch from.") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt new file mode 100644 index 0000000000..fcf072db51 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers + +import android.content.ContentResolver +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.DecryptZapQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.DeriveKeyQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.LoginQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04DecryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04EncryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44DecryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44EncryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignQuery +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +class BackgroundRequestHandler( + loggedInUser: HexKey, + packageName: String, + contentResolver: ContentResolver, +) { + val login = LoginQuery(packageName, contentResolver) + val sign = SignQuery(loggedInUser, packageName, contentResolver) + val nip04Encrypt = Nip04EncryptQuery(loggedInUser, packageName, contentResolver) + val nip04Decrypt = Nip04DecryptQuery(loggedInUser, packageName, contentResolver) + val nip44Encrypt = Nip44EncryptQuery(loggedInUser, packageName, contentResolver) + val nip44Decrypt = Nip44DecryptQuery(loggedInUser, packageName, contentResolver) + val decryptZap = DecryptZapQuery(loggedInUser, packageName, contentResolver) + val deriveKey = DeriveKeyQuery(loggedInUser, packageName, contentResolver) + + fun login() = login.query() as? SignerResult.RequestAddressed + + fun sign(unsignedEvent: Event) = sign.query(unsignedEvent) as? SignerResult.RequestAddressed + + fun nip04Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = nip04Encrypt.query(plaintext, toPubKey) as? SignerResult.RequestAddressed + + fun nip04Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = nip04Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed + + fun nip44Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = nip44Encrypt.query(plaintext, toPubKey) as? SignerResult.RequestAddressed + + fun nip44Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = nip44Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed + + fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed + + fun deriveKey(nonce: HexKey) = deriveKey.query(nonce) as? SignerResult.RequestAddressed +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt new file mode 100644 index 0000000000..c86f9ebcfc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.IntentRequestManager +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.DecryptZapRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.DeriveKeyRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04DecryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04EncryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44DecryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44EncryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DecryptZapResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DeriveKeyResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04DecryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04EncryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44DecryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +class ForegroundRequestHandler( + val loggedInUser: HexKey, + val packageName: String, + foregroundApprovalTimeout: Long = 30000, +) { + val launcher = IntentRequestManager(foregroundApprovalTimeout) + + suspend fun sign(unsignedEvent: Event) = + launcher.launchWaitAndParse( + requestIntentBuilder = { SignRequest.assemble(unsignedEvent, loggedInUser, packageName) }, + parser = { intent -> SignResponse.parse(intent, unsignedEvent) }, + ) + + suspend fun nip04Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip04EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName) }, + parser = Nip04EncryptResponse::parse, + ) + + suspend fun nip04Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip04DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName) }, + parser = Nip04DecryptResponse::parse, + ) + + suspend fun nip44Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip44EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName) }, + parser = Nip44EncryptResponse::parse, + ) + + suspend fun nip44Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip44DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName) }, + parser = Nip44DecryptResponse::parse, + ) + + suspend fun decryptZapEvent(event: LnZapRequestEvent) = + launcher.launchWaitAndParse( + requestIntentBuilder = { DecryptZapRequest.assemble(event, loggedInUser, packageName) }, + parser = DecryptZapResponse::parse, + ) + + suspend fun deriveKey(nonce: HexKey) = + launcher.launchWaitAndParse( + requestIntentBuilder = { DeriveKeyRequest.assemble(nonce, loggedInUser, packageName) }, + parser = DeriveKeyResponse::parse, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt index 099ea723a7..9b6f5a3637 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt @@ -24,9 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip56Reports.tags.DefaultReportTag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag @@ -75,28 +73,6 @@ class ReportEvent( const val KIND = 1984 const val ALT_PREFIX = "Report for " - fun create( - reportedPost: Event, - type: ReportType, - signer: NostrSigner, - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (ReportEvent) -> Unit, - ) { - val reportPostTag = arrayOf("e", reportedPost.id, type.name.lowercase()) - val reportAuthorTag = arrayOf("p", reportedPost.pubKey, type.name.lowercase()) - - var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) - - if (reportedPost is AddressableEvent) { - tags += listOf(arrayOf("a", reportedPost.aTag().toTag())) - } - - tags += listOf(AltTag.assemble("Report for ${type.name}")) - - signer.sign(createdAt, KIND, tags, content, onReady) - } - fun build( reportedPost: Event, type: ReportType, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt index 21403f5174..5393d2b4da 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt @@ -26,6 +26,12 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable @@ -37,7 +43,22 @@ class LnZapEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - LnZapEventInterface { + LnZapEventInterface, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + // This event is also kept in LocalCache (same object) @Transient val zapRequest: LnZapRequestEvent? @@ -68,9 +89,9 @@ class LnZapEvent( zapRequest = containedPost() } - override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun zappedPost() = tags.mapNotNull(ETag::parseId) - override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + override fun zappedAuthor() = tags.mapNotNull(PTag::parseKey) override fun zappedPollOption(): Int? = try { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt index 60aaae691e..f2901533ee 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt @@ -23,8 +23,14 @@ package com.vitorpamplona.quartz.nip57Zaps import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,26 +41,38 @@ class LnZapPrivateEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + companion object { const val KIND = 9733 const val ALT = "Private zap" - fun create( + suspend fun create( signer: NostrSigner, tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - onReady: (LnZapPrivateEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, tags, content, onReady) - } + ): LnZapPrivateEvent = signer.sign(createdAt, KIND, tags, content) fun create( signer: NostrSignerSync, tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - ): LnZapPrivateEvent? = signer.sign(createdAt, KIND, tags, content) + ): LnZapPrivateEvent = signer.sign(createdAt, KIND, tags, content) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 2f4dcd910e..e2768c4590 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -25,15 +25,18 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class LnZapRequestEvent( @@ -43,10 +46,21 @@ class LnZapRequestEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var privateZapEvent: LnZapPrivateEvent? = null +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (privateZapEvent?.countMemory() ?: 0) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) fun zappedPost() = tags.mapNotNull(ETag::parseId) @@ -54,43 +68,17 @@ class LnZapRequestEvent( fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } - fun getPrivateZapEvent( - loggedInUserPrivKey: ByteArray, - pubKey: HexKey, - ): LnZapPrivateEvent? { + fun getAnonTag(): String { val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } if (anonTag != null) { - val encnote = anonTag[1] - if (encnote.isNotBlank()) { - try { - val note = PrivateZapEncryption.decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray()) - val decryptedEvent = fromJson(note) - if (decryptedEvent.kind == 9733) { - return decryptedEvent as LnZapPrivateEvent - } - } catch (e: Exception) { - e.printStackTrace() - } + val encNote = anonTag[1] + if (encNote.isNotBlank()) { + return encNote + } else { + throw IllegalStateException("Anon tag is empty.") } - } - return null - } - - fun cachedPrivateZap(): LnZapPrivateEvent? = privateZapEvent - - fun decryptPrivateZap( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - privateZapEvent?.let { - onReady(it) - return - } - - signer.decryptZapEvent(this) { - // caches it - privateZapEvent = it - onReady(it) + } else { + throw IllegalStateException("This is not a private zap.") } } @@ -98,69 +86,69 @@ class LnZapRequestEvent( const val KIND = 9734 const val ALT = "Zap request" - fun create( - originalNote: Event, - relays: Set, + suspend fun create( + zappedEvent: Event, + relays: Set, signer: NostrSigner, pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, toUserPubHex: String?, createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return - + ): LnZapRequestEvent { var tags = listOf( - arrayOf("e", originalNote.id), - arrayOf("p", toUserPubHex ?: originalNote.pubKey), - arrayOf("relays") + relays, + arrayOf("e", zappedEvent.id), + arrayOf("p", toUserPubHex ?: zappedEvent.pubKey), + arrayOf("relays") + relays.map { it.url }, AltTag.assemble(ALT), ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.aTag().toTag())) + if (zappedEvent is AddressableEvent) { + tags = tags + listOf(arrayOf("a", zappedEvent.aTag().toTag())) } if (pollOption != null && pollOption >= 0) { tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) } - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags = tags + listOf(arrayOf("anon")) - NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags = tags + listOf(arrayOf("anon", "")) - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + return when (zapType) { + LnZapEvent.ZapType.PUBLIC -> signer.sign(createdAt, KIND, tags.toTypedArray(), message) + LnZapEvent.ZapType.ANONYMOUS -> { + tags = tags + listOf(arrayOf("anon")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message) + } + LnZapEvent.ZapType.PRIVATE -> { + tags = tags + listOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags.toTypedArray(), message) + } + LnZapEvent.ZapType.NONZAP -> throw IllegalArgumentException("Invalid zap type") } } - fun create( + suspend fun create( userHex: String, - relays: Set, + relays: Set, signer: NostrSigner, message: String, zapType: LnZapEvent.ZapType, createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return - + ): LnZapRequestEvent { var tags = arrayOf( arrayOf("p", userHex), - arrayOf("relays") + relays, + arrayOf("relays") + relays.map { it.url }, ) - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags += arrayOf(arrayOf("anon", "")) - NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags += arrayOf(arrayOf("anon", "")) - signer.sign(createdAt, KIND, tags, message, onReady) - } else { - signer.sign(createdAt, KIND, tags, message, onReady) + return when (zapType) { + LnZapEvent.ZapType.PUBLIC -> signer.sign(createdAt, KIND, tags, message) + LnZapEvent.ZapType.ANONYMOUS -> { + tags += arrayOf(arrayOf("anon", "")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message) + } + LnZapEvent.ZapType.PRIVATE -> { + tags += arrayOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags, message) + } + LnZapEvent.ZapType.NONZAP -> throw IllegalArgumentException("Invalid zap type") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt new file mode 100644 index 0000000000..9e8ede2061 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip57Zaps + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class PrivateZapCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(1000) { + override fun create(key: LnZapRequestEvent): PrivateZapDecryptCache? { + val zappedAuthor = key.zappedAuthor().firstOrNull() + return if (key.isPrivateZap() && zappedAuthor != null) { + PrivateZapDecryptCache(signer) + } else { + null + } + } + } + + fun delete(event: LnZapRequestEvent) { + decryptionCache.remove(event) + } + + fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? = decryptionCache[event]?.cached() + + suspend fun decryptPrivateZap(event: LnZapRequestEvent) = decryptionCache[event]?.decrypt(event) +} + +class PrivateZapDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: LnZapRequestEvent, + signer: NostrSigner, + ): LnZapPrivateEvent = signer.decryptZapEvent(event) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt index c0aca3c030..86d4ba75f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.quartz.nip57Zaps -import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event.Companion.fromJson +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.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions class PrivateZapRequestBuilder { fun signPrivateZapRequest( @@ -34,11 +36,13 @@ class PrivateZapRequestBuilder { tags: Array>, content: String, signer: NostrSignerSync, - ): T? { - if (signer.keyPair.privKey == null) return null + ): T { + if (signer.keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return null + val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } + + require(userHex != null) { "A user is required when creating private zaps" } // if it is a Zap for an Event, use event.id if not, use the user's pubkey val idToGeneratePrivateKey = zappedEvent ?: userHex @@ -52,7 +56,7 @@ class PrivateZapRequestBuilder { val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() - val privateEvent = LnZapPrivateEvent.create(signer, fullTagsNoAnon, content) ?: return null + val privateEvent = LnZapPrivateEvent.create(signer, fullTagsNoAnon, content) val noteJson = privateEvent.toJson() val encryptedContent = @@ -71,22 +75,19 @@ class PrivateZapRequestBuilder { fun decryptZapEvent( event: LnZapRequestEvent, signer: NostrSignerSync, - ): LnZapPrivateEvent? { - if (signer.keyPair.privKey == null) return null + ): LnZapPrivateEvent { + if (signer.keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() val recipientPK = event.zappedAuthor().firstOrNull() val recipientPost = event.zappedPost().firstOrNull() val privateEvent = if (recipientPK == signer.pubKey) { // if the receiver is logged in, these are the params. - val privateKeyToUse = signer.keyPair.privKey - val pubkeyToUse = event.pubKey - - event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) + decryptAnonTag(event.getAnonTag(), signer.keyPair.privKey, event.pubKey) } else { // if the sender is logged in, these are the params val altPubkeyToUse = recipientPK - val altPrivateKeyToUse = + val myPrivateKeyForThisEvent = if (recipientPost != null) { PrivateZapEncryption.createEncryptionPrivateKey( signer.keyPair.privKey.toHexKey(), @@ -100,35 +101,44 @@ class PrivateZapRequestBuilder { event.createdAt, ) } else { - null + throw SignerExceptions.CouldNotPerformException("Couldn't find a secret to use. The private zap is neither a post nor an author zap") } try { - if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = Nip01.pubKeyCreate(altPrivateKeyToUse).toHexKey() + if (altPubkeyToUse != null) { + val altPubKeyFromPrivate = Nip01.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey() if (altPubKeyFromPrivate == event.pubKey) { - val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - - if (result == null) { - Log.w( - "Private ZAP Decrypt", - "Fail to decrypt Zap from ${event.id}", - ) - } - result + // the sender is logged in. + decryptAnonTag(event.getAnonTag(), myPrivateKeyForThisEvent, altPubkeyToUse) } else { - null + throw SignerExceptions.CouldNotPerformException("This private zap cannot be decrypted by this key.") } } else { - null + throw SignerExceptions.CouldNotPerformException("Recipient pubkey not found.") } } catch (e: Exception) { - Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) - null + throw SignerExceptions.CouldNotPerformException("Failed to create pubkey for ZapRequest ${event.id}. ${e.message}") } } return privateEvent } + + fun decryptAnonTag( + encNote: String, + privateKey: ByteArray, + pubKey: HexKey, + ): LnZapPrivateEvent = + try { + val note = PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray()) + val decryptedEvent = fromJson(note) + if (decryptedEvent.kind == 9733) { + decryptedEvent as LnZapPrivateEvent + } else { + throw IllegalStateException("The decrypted event is not a private zap.") + } + } catch (e: Exception) { + throw IllegalStateException("Could not decrypt private zap. ${e.message}") + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt index dd33eb64fb..1f48c9a106 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class ZapSplitSetup( val pubKeyHex: HexKey, - val relay: String?, + val relay: NormalizedRelayUrl?, override val weight: Double, ) : BaseZapSplitSetup { override fun mainId() = pubKeyHex diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt index 4d12cca53b..3924e101b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure class ZapSplitSetupParser { @@ -40,9 +41,11 @@ class ZapSplitSetupParser { if (isLnAddress) { ZapSplitSetupLnAddress(tags[1], 1.0) } else { + val relayHint = tags.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + ZapSplitSetup( tags[1], - tags.getOrNull(2), + relayHint, weight, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt index 81a93f2473..dbef78241f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt @@ -20,13 +20,15 @@ */ package com.vitorpamplona.quartz.nip57Zaps.splits +import com.vitorpamplona.quartz.utils.arrayOfNotNull + class ZapSplitSetupSerializer { companion object { @JvmStatic fun toTagArray(zapSplit: BaseZapSplitSetup): Array = when (zapSplit) { is ZapSplitSetupLnAddress -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.lnAddress) - is ZapSplitSetup -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay ?: "", zapSplit.weight.toString()) + is ZapSplitSetup -> arrayOfNotNull(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay?.url, zapSplit.weight.toString()) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt index 8ead7f1c2e..3f94a7c8f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt @@ -23,7 +23,13 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @@ -35,7 +41,22 @@ class BadgeAwardEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun awardees() = taggedUsers() fun awardeeIds() = taggedUserIds() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt index edd9ee6951..2e47fcbcb4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt @@ -23,10 +23,15 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @Immutable class BadgeProfilesEvent( @@ -36,7 +41,22 @@ class BadgeProfilesEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun badgeAwardEvents() = taggedEvents() fun badgeAwardDefinitions() = taggedAddresses() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt index 93d2cf6b01..1a133acb3b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.EventFactory import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent class Rumor( @@ -49,9 +49,9 @@ class Rumor( } companion object { - fun fromJson(json: String): Rumor = EventMapper.mapper.readValue(json, Rumor::class.java) + fun fromJson(json: String): Rumor = JsonMapper.mapper.readValue(json, Rumor::class.java) - fun toJson(event: Rumor): String = EventMapper.mapper.writeValueAsString(event) + fun toJson(event: Rumor): String = JsonMapper.mapper.writeValueAsString(event) fun create(event: Event): Rumor = Rumor(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt new file mode 100644 index 0000000000..65e7a61fc7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip59Giftwrap.rumors + +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate + +class RumorAssembler { + companion object { + fun assembleRumor( + pubKey: HexKey, + ev: EventTemplate, + ) = assembleRumor( + pubKey, + ev.createdAt, + ev.kind, + ev.tags, + ev.content, + ) + + fun assembleRumor( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ) = EventFactory.create( + id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) as T + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index e580f23628..c65005c192 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -66,65 +66,59 @@ class SealedRumorEvent( override fun isContentEncoded() = true - fun unseal( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - try { - plainContent(signer) { - try { - val rumor = Rumor.fromJson(it) - val event = rumor.mergeWith(this) - if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind) - } - innerEventId = event.id + suspend fun unsealThrowing(signer: NostrSigner): Event { + val rumor = Rumor.fromJson(plainContent(signer)) - onReady(event) - } catch (e: Exception) { - Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e) - } - } + val event = rumor.mergeWith(this) + if (event is WrappedEvent) { + event.host = host ?: HostStub(this.id, this.pubKey, this.kind) + } + innerEventId = event.id + + return event + } + + suspend fun unsealOrNull(signer: NostrSigner): Event? { + return try { + return unsealThrowing(signer) } catch (e: Exception) { Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e) + null } } - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return + private suspend fun plainContent(signer: NostrSigner): String { + if (content.isEmpty()) return "" - signer.nip44Decrypt(content, pubKey, onReady) + return signer.nip44Decrypt(content, pubKey) } companion object { const val KIND = 13 - fun create( + suspend fun create( event: Event, encryptTo: HexKey, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SealedRumorEvent) -> Unit, - ) { + ): SealedRumorEvent { val rumor = Rumor.create(event) - create(rumor, encryptTo, signer, createdAt, onReady) + return create(rumor, encryptTo, signer, createdAt) } - fun create( + suspend fun create( rumor: Rumor, encryptTo: HexKey, signer: NostrSigner, createdAt: Long = TimeUtils.randomWithTwoDays(), - onReady: (SealedRumorEvent) -> Unit, - ) { + ): SealedRumorEvent { val msg = Rumor.toJson(rumor) - - signer.nip44Encrypt(msg, encryptTo) { content -> - signer.sign(createdAt, KIND, emptyArray(), content, onReady) - } + return signer.sign( + createdAt = createdAt, + kind = KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(msg, encryptTo), + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 73cc4ec438..9b8fd93130 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -68,40 +68,31 @@ class GiftWrapEvent( override fun isContentEncoded() = true - fun unwrapThrowing( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - plainContent(signer) { giftStr -> - val gift = fromJson(giftStr) + suspend fun unwrapThrowing(signer: NostrSigner): Event { + val giftStr = plainContent(signer) + val gift = fromJson(giftStr) - if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind) - } - innerEventId = gift.id - - onReady(gift) + if (gift is WrappedEvent) { + gift.host = HostStub(this.id, this.pubKey, this.kind) } + innerEventId = gift.id + + return gift } - fun unwrap( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { + suspend fun unwrapOrNull(signer: NostrSigner): Event? { try { - unwrapThrowing(signer, onReady) - } catch (e: Exception) { + return unwrapThrowing(signer) + } catch (_: Exception) { Log.w("GiftWrapEvent", "Couldn't Decrypt the content " + this.toNostrUri()) + return null } } - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return + private suspend fun plainContent(signer: NostrSigner): String { + if (content.isEmpty()) return "" - signer.nip44Decrypt(content, pubKey, onReady) + return signer.nip44Decrypt(content, pubKey) } fun recipientPubKey() = tags.firstTagValue("p") @@ -110,19 +101,18 @@ class GiftWrapEvent( const val KIND = 1059 const val ALT = "Encrypted event" - fun create( + suspend fun create( event: Event, recipientPubKey: HexKey, createdAt: Long = TimeUtils.randomWithTwoDays(), - onReady: (GiftWrapEvent) -> Unit, - ) { + ): GiftWrapEvent { val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key - val serializedContent = event.toJson() - val tags = arrayOf(arrayOf("p", recipientPubKey)) - - signer.nip44Encrypt(serializedContent, recipientPubKey) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } + return signer.sign( + createdAt = createdAt, + kind = KIND, + tags = arrayOf(arrayOf("p", recipientPubKey)), + content = signer.nip44Encrypt(event.toJson(), recipientPubKey), + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/RequestToVanishEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/RequestToVanishEvent.kt new file mode 100644 index 0000000000..110ce87145 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/RequestToVanishEvent.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip62RequestToVanish + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.shouldVanishFrom +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.vanishFrom +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.vanishFromEverywhere +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.vanishFromRelays +import com.vitorpamplona.quartz.utils.TimeUtils + +class RequestToVanishEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun vanishFromRelays() = tags.vanishFromRelays() + + fun shouldVanishFrom(relayUrl: String?) = tags.shouldVanishFrom(relayUrl) + + companion object { + const val KIND = 62 + const val ALT = "Request to vanish" + + fun build( + relay: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + vanishFrom(relay) + initializer() + } + + fun build( + relays: List, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + vanishFrom(relays) + initializer() + } + + fun buildVanishFromEverywhere( + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + vanishFromEverywhere() + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/RelayTag.kt new file mode 100644 index 0000000000..13a1c1eb3c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/RelayTag.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip62RequestToVanish.tags + +import android.R.attr.tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + const val EVERYWHERE = "ALL_RELAYS" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun shouldVanishFrom( + tag: Array, + relayUrl: String?, + ) = tag.has(1) && tag[0] == TAG_NAME && (tag[1] == relayUrl || tag[1] == EVERYWHERE) + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(relay: String) = arrayOf(TAG_NAME, relay) + + fun assembleEverywhere() = arrayOf(TAG_NAME, EVERYWHERE) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7d29a1bf8c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip62RequestToVanish.tags + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.relay +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import kotlin.collections.map + +fun TagArrayBuilder.vanishFromEverywhere() = add(RelayTag.assembleEverywhere()) + +fun TagArrayBuilder.vanishFrom(relay: String) = add(RelayTag.assemble(relay)) + +fun TagArrayBuilder.vanishFrom(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayExt.kt similarity index 77% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayExt.kt index 00f3f1e152..1a81d454a7 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayExt.kt @@ -18,14 +18,11 @@ * 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.ammolite.relays +package com.vitorpamplona.quartz.nip62RequestToVanish.tags -interface SubscriptionCache { - fun isActive(subscriptionId: String): Boolean +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.any - fun allSubscriptions(): Map> +fun TagArray.vanishFromRelays() = mapNotNull(RelayTag::parse) - fun getSubscriptionFilters(subId: String): List - - fun getSubscriptionFiltersOrNull(subId: String): List? -} +fun TagArray.shouldVanishFrom(relayUrl: String?) = any(RelayTag::shouldVanishFrom, relayUrl) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt index ab53f0a072..315f406b3d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,48 +39,15 @@ class AdvertisedRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - val type = - when (it.getOrNull(2)) { - "read" -> AdvertisedRelayType.READ - "write" -> AdvertisedRelayType.WRITE - else -> AdvertisedRelayType.BOTH - } + fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) - AdvertisedRelayInfo(it[1], type) - } else { - null - } - } + fun readRelays() = tags.mapNotNull(AdvertisedRelayInfo::parseRead).ifEmpty { null } - fun readRelays(): List? = - tags - .mapNotNull { - if (it.size > 1 && it[0] == "r") { - when (it.getOrNull(2)) { - "read" -> it[1] - "write" -> null - else -> it[1] - } - } else { - null - } - }.ifEmpty { null } + fun readRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseReadNorm).ifEmpty { null } - fun writeRelays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - when (it.getOrNull(2)) { - "read" -> null - "write" -> it[1] - else -> it[1] - } - } else { - null - } - } + fun writeRelays() = tags.mapNotNull(AdvertisedRelayInfo::parseWrite).ifEmpty { null } + + fun writeRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseWriteNorm).ifEmpty { null } companion object { const val KIND = 10002 @@ -92,80 +59,42 @@ class AdvertisedRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun updateRelayList( + suspend fun replaceRelayListWith( earlierVersion: AdvertisedRelayListEvent, + newRelays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): AdvertisedRelayListEvent { + val tags = earlierVersion.tags.filter(AdvertisedRelayInfo::notMatch) + val relayTags = newRelays.map { it.toTagArray() } + + return signer.sign(createdAt, KIND, (tags + relayTags).toTypedArray(), earlierVersion.content) + } + + suspend fun createFromScratch( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter { it[0] != "r" } - .plus( - relays.map(Companion::createRelayTag), - ).toTypedArray() + ) = create(relays, signer, createdAt) - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) - } - - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun createRelayTag(relay: AdvertisedRelayInfo): Array = - if (relay.type == AdvertisedRelayType.BOTH) { - arrayOf("r", relay.relayUrl) - } else { - arrayOf("r", relay.relayUrl, relay.type.code) - } - - fun createTagArray(relays: List): Array> = - relays - .map(Companion::createRelayTag) - .plusElement(AltTag.assemble(ALT)) - .toTypedArray() - - fun create( + suspend fun create( list: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - val tags = createTagArray(list) - val msg = "" + ): AdvertisedRelayListEvent { + val tags = list.map { it.toTagArray() }.toTypedArray() - signer.sign(createdAt, KIND, tags, msg, onReady) + return signer.sign(createdAt, KIND, tags, "") } fun create( list: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): AdvertisedRelayListEvent? { - val tags = createTagArray(list) - val msg = "" + ): AdvertisedRelayListEvent { + val tags = list.map { it.toTagArray() }.toTypedArray() - return signer.sign(createdAt, KIND, tags, msg) + return signer.sign(createdAt, KIND, tags, "") } } - - @Immutable data class AdvertisedRelayInfo( - val relayUrl: String, - val type: AdvertisedRelayType, - ) - - @Immutable - enum class AdvertisedRelayType( - val code: String, - ) { - BOTH(""), - READ("read"), - WRITE("write"), - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt index 88be891cf8..5856ed926f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt @@ -21,31 +21,27 @@ package com.vitorpamplona.quartz.nip65RelayList import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import com.vitorpamplona.quartz.utils.mapOfSet class RelayListRecommendationProcessor { companion object { fun transpose( - userList: Map>, - ignore: Set = setOf(), - ): Map> { - val popularity = mutableMapOf>() - - userList.forEach { event -> - event.value.forEach { relayUrl -> - if (relayUrl !in ignore) { - val set = popularity[relayUrl] - if (set != null) { - set.add(event.key) - } else { - popularity[relayUrl] = mutableSetOf(event.key) + userList: Map>, + ignore: Set = setOf(), + ): Map> = + mapOfSet { + userList.forEach { event -> + event.value.forEach { relay -> + if (relay !in ignore) { + add(relay, event.key) } } } } - return popularity - } - /** * filter onion and local host from write relays * for each user pubkey, a list of valid relays. @@ -53,30 +49,20 @@ class RelayListRecommendationProcessor { private fun filterValidRelays( userList: List, hasOnionConnection: Boolean = false, - ): MutableMap> { - val validWriteRelayUrls = mutableMapOf>() - - userList.forEach { event -> - event.writeRelays().forEach { relayUrl -> - if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { - RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> - val set = validWriteRelayUrls[event.pubKey] - if (set != null) { - set.add(normRelayUrl) - } else { - validWriteRelayUrls[event.pubKey] = mutableSetOf(normRelayUrl) - } + ): Map> = + mapOfSet { + userList.forEach { event -> + event.writeRelaysNorm()?.forEach { relay -> + if (!relay.isLocalHost() && (hasOnionConnection || !relay.isOnion())) { + add(event.pubKey, relay) } } } } - return validWriteRelayUrls - } - fun reliableRelaySetFor( - usersAndRelays: MutableMap>, - relayUrlsToIgnore: Set = emptySet(), + usersAndRelays: Map>, + relayUrlsToIgnore: Set = emptySet(), ): Set { // ignores users that are already being served by the list. val usersToServeInTheFirstRound = @@ -145,7 +131,7 @@ class RelayListRecommendationProcessor { fun reliableRelaySetFor( userList: List, - relayUrlsToIgnore: Set = emptySet(), + relayUrlsToIgnore: Set = emptySet(), hasOnionConnection: Boolean = false, ): Set = reliableRelaySetFor( @@ -155,7 +141,7 @@ class RelayListRecommendationProcessor { } class RelayRecommendation( - val url: String, + val relay: NormalizedRelayUrl, val requiredToNotMissEvents: Boolean, val users: Set, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt deleted file mode 100644 index fdfeb3bf6a..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip65RelayList - -import org.czeal.rfc3986.URIReference - -class RelayUrlFormatter { - companion object { - fun displayUrl(url: String): String = - url - .trim() - .removePrefix("wss://") - .removePrefix("ws://") - .removeSuffix("/") - - fun isLocalHost(url: String) = url.contains("127.0.0.1") || url.contains("localhost") - - fun isOnion(url: String) = url.endsWith(".onion") || url.endsWith(".onion/") - - fun normalize(url: String): String { - val newUrl = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (isOnion(url) || isLocalHost(url)) { - "ws://${url.trim()}" - } else { - "wss://${url.trim()}" - } - } else { - url.trim() - } - - return try { - URIReference.parse(newUrl).normalize().toString() - } catch (e: Exception) { - newUrl - } - } - - fun normalizeOrNull(url: String): String? { - val newUrl = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (isOnion(url) || isLocalHost(url)) { - "ws://${url.trim()}" - } else { - "wss://${url.trim()}" - } - } else { - url.trim() - } - - return try { - URIReference.parse(newUrl).normalize().toString() - } catch (e: Exception) { - null - } - } - - fun getHttpsUrl(dirtyUrl: String): String = - if (dirtyUrl.contains("://")) { - dirtyUrl.replace("wss://", "https://").replace("ws://", "http://") - } else { - "https://$dirtyUrl" - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt new file mode 100644 index 0000000000..bdde75e007 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip65RelayList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.ensure + +class AdvertisedRelayInfo( + val relayUrl: NormalizedRelayUrl, + val type: AdvertisedRelayType, +) { + fun toTagArray() = assemble(relayUrl, type) + + companion object { + const val TAG_NAME = "r" + + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun notMatch(tag: Array) = !match(tag) + + @JvmStatic + fun parse(tag: Array): AdvertisedRelayInfo? { + ensure(match(tag)) { return null } + + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(normalizedUrl != null) { return null } + + val type = + when (tag.getOrNull(2)) { + AdvertisedRelayType.READ.code -> AdvertisedRelayType.READ + AdvertisedRelayType.WRITE.code -> AdvertisedRelayType.WRITE + else -> AdvertisedRelayType.BOTH + } + + return AdvertisedRelayInfo(normalizedUrl, type) + } + + @JvmStatic + fun parseRead(tag: Array): String? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null } + + return tag[1] + } + + @JvmStatic + fun parseWrite(tag: Array): String? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null } + + return tag[1] + } + + @JvmStatic + fun parseReadNorm(tag: Array): NormalizedRelayUrl? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(relay != null && !relay.isLocalHost()) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + @JvmStatic + fun parseWriteNorm(tag: Array): NormalizedRelayUrl? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(relay != null && !relay.isLocalHost()) { return null } + + return relay + } + + @JvmStatic + fun assemble( + relay: NormalizedRelayUrl, + type: AdvertisedRelayType, + ): Array = + if (type == AdvertisedRelayType.BOTH) { + arrayOf(TAG_NAME, relay.url) + } else { + arrayOf(TAG_NAME, relay.url, type.code) + } + } +} + +@Immutable +enum class AdvertisedRelayType( + val code: String, +) { + BOTH(""), + READ("read"), + WRITE("write"), + ; + + fun isRead() = this == READ || this == BOTH + + fun isWrite() = this == WRITE || this == BOTH + + companion object { + fun isRead(type: String?) = type == null || type.isBlank() || type == AdvertisedRelayType.READ.code + + fun isWrite(type: String?) = type == null || type.isBlank() || type == AdvertisedRelayType.WRITE.code + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt index bb0bca02ad..7315ecf396 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt @@ -24,25 +24,16 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip68Picture.tags.LocationTag import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash -import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -55,63 +46,21 @@ class PictureEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { - // --------------- - // current - // -------------- + @Transient var iMetas: List? = null fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - /** old standard didnt use IMetas **/ - private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - private fun urls() = tags.mapNotNull(UrlTag::parse) + fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + fun hashtags() = tags.hashtags() - private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + fun geohashes() = tags.geohashes() - private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) + fun location() = tags.mapNotNull(LocationTag::parse) - private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - - private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - - private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - - private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - - private fun hasUrl() = tags.any(UrlTag::isTag) - - private fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) - - private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - - private fun images() = tags.mapNotNull(ImageTag::parse) - - private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - - private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) - - private fun fallbacks() = tags.mapNotNull(FallbackTag::parse) - - // hack to fix pablo's bug - fun rootImage() = - url()?.let { - PictureMeta( - url = it, - mimeType = mimeType(), - blurhash = blurhash(), - alt = alt(), - hash = hash(), - dimension = dimensions(), - size = size(), - service = service(), - fallback = fallbacks(), - annotations = emptyList(), - ) - } - - fun imetaTags() = imetas().map { PictureMeta.parse(it) }.plus(rootImage()).filterNotNull() + fun imetaTags() = iMetas ?: imetas().map { PictureMeta.parse(it) }.also { iMetas = it } companion object { const val KIND = 20 @@ -142,7 +91,7 @@ class PictureEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { - alt(ClassifiedsEvent.ALT_DESCRIPTION) + alt(ALT_DESCRIPTION) initializer() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/LocationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/LocationTag.kt new file mode 100644 index 0000000000..a46e0e2ee1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/LocationTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip68Picture.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class LocationTag { + companion object { + const val TAG_NAME = "location" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(locationName: String) = arrayOf(TAG_NAME, locationName) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index 0edd44b83a..2d2b61cc9f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -23,29 +23,17 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag -import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip71Video.tags.DurationTag import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash -import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @Immutable abstract class VideoEvent( @@ -58,59 +46,7 @@ abstract class VideoEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), RootScope { - /** old standard didnt use IMetas **/ - private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) - - private fun urls() = tags.mapNotNull(UrlTag::parse) - - private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - - private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - - private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) - - private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - - private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - - private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - - private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - - private fun hasUrl() = tags.any(UrlTag::isTag) - - private fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) - - private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - - private fun images() = tags.mapNotNull(ImageTag::parse) - - private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - - private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) - - private fun fallbacks() = tags.mapNotNull(FallbackTag::parse) - - // hack to fix pablo's bug - fun rootVideo() = - url()?.let { - VideoMeta( - url = it, - mimeType = mimeType(), - blurhash = blurhash(), - alt = alt(), - hash = hash(), - dimension = dimensions(), - size = size(), - service = service(), - fallback = fallbacks(), - image = images().map { it.imageUrl }, - ) - } - - // --------------- - // current - // -------------- + @Transient var iMetas: List? = null fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) @@ -126,5 +62,9 @@ abstract class VideoEvent( fun hashtags() = tags.hashtags() - fun imetaTags() = imetas().map { VideoMeta.parse(it) }.plus(rootVideo()).filterNotNull() + private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + + private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + + fun imetaTags() = iMetas ?: imetas().map { VideoMeta.parse(it) }.also { iMetas = it } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt deleted file mode 100644 index f304c1283f..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip72ModCommunities - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent.Companion.FIXED_D_TAG -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.parseAtag -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -class CommunityListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - 32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.countMemory() } ?: 0L) // rough calculation - - override fun dTag() = FIXED_D_TAG - - fun publicAndPrivateEvents( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateEventCache = - filterTagList("a", it) - .mapNotNull { ATag.parseAtag(it, null) } - .toImmutableSet() - - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - } - } - } - - companion object { - const val KIND = 10004 - const val ALT = "Community List" - - fun blockListFor(pubKeyHex: HexKey): String = "$KIND:$pubKeyHex:" - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithEvent( - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = createListWithTag("a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun addEvents( - earlierVersion: CommunityListEvent, - listAddresses: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listAddresses.map { arrayOf("a", it.toTag()) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listAddresses.map { arrayOf("a", it.toTag()) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addEvent( - earlierVersion: CommunityListEvent, - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: CommunityListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeEvent( - earlierVersion: CommunityListEvent, - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: CommunityListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt index 5b404a33d3..6cba530dd5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt @@ -25,12 +25,19 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,7 +50,22 @@ class CommunityPostApprovalEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun containedPost(): Event? = try { content.ifBlank { null }?.let { fromJson(it) } @@ -68,6 +90,7 @@ class CommunityPostApprovalEvent( companion object { const val KIND = 4550 const val ALT_DESCRIPTION = "Community post approval" + val KIND_LIST = listOf(CommunityPostApprovalEvent.KIND) fun build( approvedPost: EventHintBundle, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt index 0498313350..11ffee913f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt @@ -24,8 +24,15 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag @@ -44,7 +51,25 @@ class CommunityDefinitionEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + + override fun addressHints() = + tags.mapNotNull(ATag::parseAsHint) + + tags.mapNotNull(QTag::parseAddressAsHint) + + tags.mapNotNull(RelayTag::parse).map { AddressHint(addressTag(), it.url) } + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(ModeratorTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ModeratorTag::parseKey) + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) @@ -59,6 +84,8 @@ class CommunityDefinitionEvent( fun relays() = tags.mapNotNull(RelayTag::parse) + fun relayUrls() = tags.mapNotNull(RelayTag::parseUrls) + companion object { const val KIND = 34550 const val ALT_DESCRIPTION = "Community definition" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt index 199446bfac..2bccc47901 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt @@ -24,6 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -31,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ModeratorTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, val role: String?, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint, role) @@ -44,7 +47,10 @@ data class ModeratorTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ModeratorTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ModeratorTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -55,11 +61,24 @@ data class ModeratorTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, role: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint, role) + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url, role) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt index 948f4f35bb..09e62790d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -21,11 +21,13 @@ package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure class RelayTag( - val url: String, + val url: NormalizedRelayUrl, val marker: String? = null, ) { fun toTagArray() = assemble(url, marker) @@ -38,13 +40,27 @@ class RelayTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return RelayTag(tag[1], tag.getOrNull(2)) + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return RelayTag(relay, tag.getOrNull(2)) + } + + @JvmStatic + fun parseUrls(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay } @JvmStatic fun assemble( - url: String, + relay: NormalizedRelayUrl, marker: String? = null, - ) = arrayOfNotNull(TAG_NAME, url, marker) + ) = arrayOfNotNull(TAG_NAME, relay.url, marker) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt new file mode 100644 index 0000000000..aaaaab456e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class CommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(CommunityTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(CommunityTag::parseAddressId) + + fun publicCommunities() = tags.communities() + + fun publicCommunityIds() = tags.communityIds() + + companion object { + const val KIND = 10004 + const val ALT = "Community List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + communities: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + create( + publicCommunities = emptyList(), + privateCommunities = communities, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicCommunities = communities, + privateCommunities = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun create( + community: CommunityTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + create( + publicCommunities = emptyList(), + privateCommunities = listOf(community), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicCommunities = listOf(community), + privateCommunities = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: CommunityListEvent, + communities: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(communities.map { it.toTagIdOnly() }) + communities.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(communities.map { it.toTagIdOnly() }) + communities.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: CommunityListEvent, + community: CommunityTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(community.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(community.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: CommunityListEvent, + community: CommunityTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(community.toTagIdOnly()), + tags = earlierVersion.tags.remove(community.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicCommunities: List = emptyList(), + privateCommunities: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent { + val template = build(publicCommunities, privateCommunities, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicCommunities: List = emptyList(), + privateCommunities: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateCommunities.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + communities(publicCommunities) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..af274d9efc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.communities(communities: List) = addAll(communities.map { it.toTagArray() }) + +fun TagArrayBuilder.followCommunity( + address: Address, + relayUrl: NormalizedRelayUrl, +) = addUnique(ATag.assemble(address, relayUrl)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayExt.kt new file mode 100644 index 0000000000..ca2b50b67b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag + +fun TagArray.communities() = mapNotNull(CommunityTag::parse) + +fun TagArray.communityIds() = mapNotNull(CommunityTag::parseAddressId) + +fun TagArray.communityIdSet() = mapNotNullTo(mutableSetOf(), CommunityTag::parseValidAddress) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/tags/CommunityTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/tags/CommunityTag.kt new file mode 100644 index 0000000000..7b6bcc6d35 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/tags/CommunityTag.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class CommunityTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + + companion object { + const val TAG_NAME = "a" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: CommunityTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parse( + aTagId: String, + relay: String?, + ) = Address.parse(aTagId)?.let { + CommunityTag(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + } + + @JvmStatic + fun parse(tag: Array): CommunityTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return parse(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + @JvmStatic + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1]) + } + + @JvmStatic + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt index 04babfa41e..580379a3d2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt @@ -24,26 +24,6 @@ import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure import kotlin.math.min -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ class MovieId( val isan: String, val hint: String? = null, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt index 50b4be0a34..1198100ae3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt @@ -23,26 +23,6 @@ package com.vitorpamplona.quartz.nip73ExternalIds.topics import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ class HashtagId( val topic: String, val hint: String? = null, @@ -70,23 +50,21 @@ class HashtagId( return encoded.indexOf(value, 1) > 0 } - fun parse(encoded: String): String? { - return if (encoded.length > 1 && encoded[0] == KIND_CHR) { + fun parse(encoded: String): String? = + if (encoded.length > 1 && encoded[0] == KIND_CHR) { encoded.substring(1) } else { null } - } fun parse( encoded: String, hint: String?, - ): HashtagId? { - return if (encoded.length > 1 && encoded[0] == KIND_CHR) { + ): HashtagId? = + if (encoded.length > 1 && encoded[0] == KIND_CHR) { HashtagId(encoded.substring(1), hint) } else { null } - } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt index 9cc32f363c..293ecde409 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt @@ -26,9 +26,15 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.references.reference import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag @@ -46,7 +52,22 @@ class GoalEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun topics() = hashtags() fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index c3725b4651..6dcd9a31eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -52,14 +52,13 @@ class AppSpecificDataEvent( dTag: String, ): ATag = ATag(KIND, pubkey, dTag, null) - fun create( + suspend fun create( dTag: String, description: String, otherTags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppSpecificDataEvent) -> Unit, - ) { + ): AppSpecificDataEvent { val withD = if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { otherTags @@ -74,7 +73,7 @@ class AppSpecificDataEvent( withD } - signer.sign(createdAt, KIND, newTags, description, onReady) + return signer.sign(createdAt, KIND, newTags, description) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index e9eac01f6f..b1be670b95 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -22,15 +22,32 @@ package com.vitorpamplona.quartz.nip84Highlights import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedAddress +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -44,7 +61,59 @@ class HighlightEvent( content: String, sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = "comment: " + comment() + "\ncontext: " + context() + "\n" + content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(ETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(ETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse) fun author() = firstTaggedUser() @@ -65,13 +134,10 @@ class HighlightEvent( const val KIND = 9802 const val ALT = "Highlight/quote event" - fun create( + suspend fun create( msg: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (HighlightEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, arrayOf(AltTag.assemble(ALT)), msg, onReady) - } + ): HighlightEvent = signer.sign(createdAt, KIND, arrayOf(AltTag.assemble(ALT)), msg) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt new file mode 100644 index 0000000000..2810fe19ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ClientTag( + val name: String, + val address: Address?, + val relayHint: NormalizedRelayUrl?, +) { + fun toTagArray() = assemble(name, address, relayHint) + + companion object { + const val TAG_NAME = "client" + + @JvmStatic + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): ClientTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val address = tag.getOrNull(2)?.let { Address.parse(it) } + val relayHint = tag.getOrNull(3)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ClientTag( + tag[1], + address, + relayHint, + ) + } + + @JvmStatic + fun assemble(name: String) = arrayOfNotNull(TAG_NAME, name) + + @JvmStatic + fun assemble( + name: String, + address: String? = null, + relayHint: NormalizedRelayUrl? = null, + ) = arrayOfNotNull(TAG_NAME, name, address, relayHint?.url) + + @JvmStatic + fun assemble( + name: String, + address: Address? = null, + relayHint: NormalizedRelayUrl? = null, + ) = assemble(name, address?.toValue(), relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt new file mode 100644 index 0000000000..81b92c0393 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount + +fun Event.client() = tags.zapraiserAmount() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt similarity index 73% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index a3d70ff3ef..566492c985 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -18,20 +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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -interface SubscriptionCollection { - fun isActive(subscriptionId: String): Boolean +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) - fun getFilters(subscriptionId: String): List - - fun allSubscriptions(): List - - fun match( - subscriptionId: String, - event: Event, - ): Boolean -} +fun TagArrayBuilder.client( + name: String, + address: AddressHint, +) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt new file mode 100644 index 0000000000..3516fad880 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.client() = this.mapNotNull(ClientTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index 355fb3de89..f2d77522ea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException import java.util.UUID @Immutable @@ -55,12 +56,19 @@ class AppDefinitionEvent( cachedMetadata } else { try { - val newMetadata = AppMetadata.parse(content) - cachedMetadata = newMetadata - newMetadata + if (content.startsWith("{")) { + val newMetadata = AppMetadata.parse(content) + cachedMetadata = newMetadata + newMetadata + } else { + val newMetadata = AppMetadata() + newMetadata.name = content + cachedMetadata = newMetadata + newMetadata + } } catch (e: Exception) { - e.printStackTrace() - Log.w("AppDefinitionEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}") + if (e is CancellationException) throw e + Log.w("AppDefinitionEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}", e) null } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt index 654dad7be4..2857593eb6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip89AppHandlers.definition import androidx.compose.runtime.Stable import com.fasterxml.jackson.annotation.JsonProperty -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -113,8 +113,8 @@ class AppMetadata { fun toJson() = assemble(this) companion object { - fun assemble(data: AppMetadata) = EventMapper.mapper.writeValueAsString(data) + fun assemble(data: AppMetadata) = JsonMapper.mapper.writeValueAsString(data) - fun parse(content: String) = EventMapper.mapper.readValue(content, AppMetadata::class.java) + fun parse(content: String) = JsonMapper.mapper.readValue(content, AppMetadata::class.java) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt index d82fe05224..04291e4dfa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt @@ -40,7 +42,12 @@ class AppRecommendationEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(RecommendationTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(RecommendationTag::parseAddressId) + fun recommendations() = tags.mapNotNull(RecommendationTag::parse) fun recommendationAddresses() = tags.mapNotNull(RecommendationTag::parseAddressId) @@ -51,7 +58,7 @@ class AppRecommendationEvent( class AppRecommendationItem( val appDefinitionEvent: AppDefinitionEvent, - val relayHint: String?, + val relayHint: NormalizedRelayUrl?, val platform: PlatformType, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt index 68f7318f54..96122fd363 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt @@ -21,18 +21,19 @@ package com.vitorpamplona.quartz.nip89AppHandlers.recommendation import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag fun TagArrayBuilder.recommend( addressId: String, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, ) = add(RecommendationTag.assemble(addressId, relay, platform)) fun TagArrayBuilder.recommend( address: Address, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, ) = add(RecommendationTag.assemble(address, relay, platform)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt index 1a48b20fad..4056e736eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -30,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class RecommendationTag( val address: Address, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, val platform: String? = null, ) { fun toTagArray() = assemble(address, relay, platform) @@ -47,7 +51,8 @@ class RecommendationTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - return RecommendationTag(address, tag.getOrNull(2), tag.getOrNull(3)) + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return RecommendationTag(address, relayHint, tag.getOrNull(3)) } @JvmStatic @@ -58,18 +63,31 @@ class RecommendationTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == ATag.Companion.TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + + return AddressHint(tag[1], relayHint) + } + @JvmStatic fun assemble( addressId: String, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay, platform) + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url, platform) @JvmStatic fun assemble( address: Address, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay, platform) + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url, platform) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt index c2c44c5b85..addd3c39c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -42,22 +43,21 @@ class NIP90ContentDiscoveryRequestEvent( const val KIND = 5300 const val ALT = "NIP90 Content Discovery request" - fun create( + suspend fun create( dvmPublicKey: HexKey, forUser: HexKey, - relays: Set, + relays: Set, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, - ) { + ): NIP90ContentDiscoveryRequestEvent { val content = "" val tags = mutableListOf>() tags.add(arrayOf("p", dvmPublicKey)) tags.add(AltTag.assemble(ALT)) - tags.add(arrayOf("relays") + relays.toTypedArray()) + tags.add(arrayOf("relays") + relays.map { it.url }.toTypedArray()) tags.add(arrayOf("param", "max_results", "200")) tags.add(arrayOf("param", "user", forUser)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt index c781ebcada..d7ca0d2ac7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt @@ -25,10 +25,9 @@ import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -59,7 +58,7 @@ class NIP90ContentDiscoveryResponseEvent( try { events = - EventMapper.mapper.readValue>>(content).mapNotNull { + JsonMapper.mapper.readValue>>(content).mapNotNull { if (it.size > 1 && it[0] == "e") { it[1] } else { @@ -77,16 +76,15 @@ class NIP90ContentDiscoveryResponseEvent( const val KIND = 6300 const val ALT = "NIP90 Content Discovery reply" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90ContentDiscoveryResponseEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt index 950aecf242..a36411df7f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -78,16 +77,15 @@ class NIP90StatusEvent( const val KIND = 7000 const val ALT = "NIP90 Status update" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90StatusEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt index 414c929743..6d4cf85e5e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,16 +40,15 @@ class NIP90UserDiscoveryRequestEvent( const val KIND = 5301 const val ALT = "NIP90 Content Discovery request" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90UserDiscoveryRequestEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt index c4d9978345..a3e619c751 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,16 +40,15 @@ class NIP90UserDiscoveryResponseEvent( const val KIND = 6301 const val ALT = "NIP90 Content Discovery reply" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90UserDiscoveryResponseEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt index 3bcba1c14c..237b11d6bb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt @@ -48,7 +48,7 @@ class IMetaTag( ensure(tag[1].isNotEmpty()) { return null } val allTags = parseIMeta(tag) - val url = allTags.get(ANCHOR_PROPERTY)?.firstOrNull() + val url = allTags[ANCHOR_PROPERTY]?.firstOrNull() return if (url != null) { IMetaTag(url, allTags.minus(ANCHOR_PROPERTY)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt new file mode 100644 index 0000000000..8d035e7206 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag + +data class AudioMeta( + val url: String, + val mimeType: String? = null, + val hash: String? = null, + val duration: Int? = null, + val waveform: List? = null, +) { + fun toIMetaArray(): Array = + IMetaTagBuilder(url) + .apply { + mimeType?.let { mimeType(it) } + hash?.let { hash(it) } + duration?.let { duration(it) } + waveform?.let { waveform(it) } + }.build() + .toTagArray() + + companion object { + fun parse(iMeta: IMetaTag): AudioMeta = + AudioMeta( + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + duration = iMeta.duration()?.firstOrNull()?.toIntOrNull(), + waveform = iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) }, + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt similarity index 61% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt index b43207bf4e..d6689ad5f2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt @@ -18,43 +18,40 @@ * 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.quartz.nip51Lists +package com.vitorpamplona.quartz.nipA0VoiceMessages import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class RelaySetEvent( +open class BaseVoiceEvent( id: HexKey, pubKey: HexKey, createdAt: Long, + kind: Int, tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun iMetaTags() = imetas().map { AudioMeta.parse(it) } companion object { - const val KIND = 30002 - const val ALT = "Relay list" - - fun create( - relays: List, - signer: NostrSigner, + fun build( + voiceMessage: AudioMeta, + kind: Int, + alt: String, createdAt: Long = TimeUtils.now(), - onReady: (RelaySetEvent) -> Unit, - ) { - val tags = mutableListOf>() - relays.forEach { tags.add(arrayOf("r", it)) } - tags.add(AltTag.assemble(ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(kind, voiceMessage.url, createdAt) { + alt(alt) + audioIMeta(voiceMessage) + initializer() } } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt similarity index 56% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt index 9d78017a3f..4562d52754 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt @@ -18,27 +18,22 @@ * 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.ammolite.relays +package com.vitorpamplona.quartz.nipA0VoiceMessages -class MutableSubscriptionCache : SubscriptionCache { - private var subscriptions = mapOf>() +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag - fun add( - subscriptionId: String, - filters: List = listOf(), - ) { - subscriptions = subscriptions + Pair(subscriptionId, filters) - } +/** + * Contains the IMeta tags that are used by Picture events. + */ +fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash) - fun remove(subscriptionId: String) { - subscriptions = subscriptions.minus(subscriptionId) - } +fun IMetaTagBuilder.duration(size: Int) = add(DurationTag.TAG_NAME, size.toString()) - override fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId) +fun IMetaTagBuilder.waveform(wave: List) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWave(wave)) - override fun allSubscriptions(): Map> = subscriptions - - override fun getSubscriptionFilters(subId: String): List = subscriptions[subId] ?: emptyList() - - override fun getSubscriptionFiltersOrNull(subId: String): List? = subscriptions[subId] -} +fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt new file mode 100644 index 0000000000..370f830f08 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag + +fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME) + +fun IMetaTag.duration() = properties.get(DurationTag.TAG_NAME) + +fun IMetaTag.waveform() = properties.get(WaveformTag.TAG_NAME) + +fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f482eb2019 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag + +fun TagArrayBuilder.audioIMeta( + url: String, + mimeType: String? = null, + hash: String? = null, + duration: Int? = null, + waveform: List? = null, +) = audioIMeta(AudioMeta(url, mimeType, hash, duration, waveform)) + +fun TagArrayBuilder.audioIMeta(imeta: AudioMeta): TagArrayBuilder { + add(imeta.toIMetaArray()) + imeta.hash?.let { add(HashSha256Tag.assemble(it)) } + return this +} + +fun TagArrayBuilder.audioIMeta(audioUrls: List): TagArrayBuilder { + audioUrls.forEach { audioIMeta(it) } + return this +} + +fun TagArrayBuilder.replyEvent( + eventId: String, + relayHint: NormalizedRelayUrl?, + pubkey: String?, +) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) + +fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind)) + +fun TagArrayBuilder.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind)) + +fun TagArrayBuilder.replyAuthor( + pubKey: HexKey, + relay: NormalizedRelayUrl?, +) = add(ReplyAuthorTag.assemble(pubKey, relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt new file mode 100644 index 0000000000..33f30d596f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class VoiceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 1222 + const val ALT_DESCRIPTION = "Voice message" + + fun build( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + ) = build(AudioMeta(url, mimeType, hash, duration, waveform)) + + fun build( + voiceMessage: AudioMeta, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt, initializer) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt new file mode 100644 index 0000000000..eb7444881d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull + +@Immutable +class VoiceReplyEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse) + + fun replyAuthors() = tags.filter(ReplyAuthorTag::match) + + fun replyAuthorKeys() = tags.mapNotNull(ReplyAuthorTag::parseKey) + + fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint) + + fun directReplies() = tags.filter { ReplyEventTag.match(it) } + + fun directKinds() = tags.filter(ReplyKindTag::match) + + fun markedReplyTos(): List = tags.mapNotNull(ReplyEventTag::parseKey) + + fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) + + companion object { + const val KIND = 1244 + const val ALT_DESCRIPTION = "Voice reply" + + fun build( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + replyingTo: EventHintBundle, + ) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo) + + fun build( + voiceMessage: AudioMeta, + replyingTo: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) { + replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey) + replyKind(replyingTo.event.kind) + replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/DurationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/DurationTag.kt new file mode 100644 index 0000000000..af56d81f2d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/DurationTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + @JvmStatic + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(seconds: Int) = arrayOf(TAG_NAME, seconds.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/HashSha256Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/HashSha256Tag.kt new file mode 100644 index 0000000000..9f42b8ad7f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/HashSha256Tag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class HashSha256Tag { + companion object { + const val TAG_NAME = "sha256" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt new file mode 100644 index 0000000000..2dc30e0397 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class MimeTypeTag { + companion object { + const val TAG_NAME = "m" + + fun isIn( + tag: Array, + mimeTypes: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in mimeTypes + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt new file mode 100644 index 0000000000..5853f5f7ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class ReplyAuthorTag( + override val pubKey: HexKey, + override val relayHint: NormalizedRelayUrl? = null, +) : PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint) + + companion object { + const val TAG_NAME = "p" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Tag): ReplyAuthorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt new file mode 100644 index 0000000000..08c10fd9fa --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class ReplyEventTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) + + fun toTagArray() = assemble(ref) + + companion object { + const val TAG_NAME = "e" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + eventId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun isIn( + tag: Array, + eventIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds + + @JvmStatic + fun parse(tag: Array): ReplyEventTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3)) + } + + @JvmStatic + fun parseKey(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseValidKey(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(Hex.isHex(tag[1])) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + pubkey: String?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + @JvmStatic + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt new file mode 100644 index 0000000000..7ecb255b7f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class ReplyKindTag { + companion object { + const val TAG_NAME = "k" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isKind( + tag: Tag, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind + + @JvmStatic + fun isTagged( + tag: Tag, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind + + @JvmStatic + fun isIn( + tag: Tag, + kinds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in kinds + + @JvmStatic + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(kind: String) = arrayOf(TAG_NAME, kind) + + @JvmStatic + fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString()) + + @JvmStatic + fun assemble(id: ExternalId) = assemble(id.toKind()) + + @JvmStatic + fun assemble(kinds: List): List = kinds.map { assemble(it) } + + @JvmStatic + fun assemble(kinds: Set): List = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/WaveformTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/WaveformTag.kt new file mode 100644 index 0000000000..d4366fcba5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/WaveformTag.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class WaveformTag( + val wave: List, +) { + fun toTagArray() = assemble(wave) + + companion object { + const val TAG_NAME = "waveform" + + @JvmStatic + fun parse(tag: Array): WaveformTag? = parseWave(tag)?.let { WaveformTag(it) } + + @JvmStatic + fun parseWave(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val wave = tag[1].split(" ").mapNotNull { it.toIntOrNull() } + if (wave.isEmpty()) return null + return wave + } + + fun parseWave(wave: String): List? { + val wave = wave.split(" ").mapNotNull { it.toIntOrNull() } + if (wave.isEmpty()) return null + return wave + } + + @JvmStatic + fun assembleWave(wave: List) = wave.joinToString(" ") + + @JvmStatic + fun assemble(wave: List) = arrayOf(TAG_NAME, assembleWave(wave)) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt similarity index 82% rename from quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index 4fa1ad9ba1..ca00e94991 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -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.quartz.blossom +package com.vitorpamplona.quartz.nipB7Blossom import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -38,47 +38,42 @@ class BlossomAuthorizationEvent( companion object { const val KIND = 24242 - fun createGetAuth( + suspend fun createGetAuth( hash: HexKey, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("get", hash, null, alt, signer, createdAt, onReady) + ) = createAuth("get", hash, null, alt, signer, createdAt) - fun createListAuth( + suspend fun createListAuth( signer: NostrSigner, alt: String, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("list", null, null, alt, signer, createdAt, onReady) + ) = createAuth("list", null, null, alt, signer, createdAt) - fun createDeleteAuth( + suspend fun createDeleteAuth( hash: HexKey, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("delete", hash, null, alt, signer, createdAt, onReady) + ) = createAuth("delete", hash, null, alt, signer, createdAt) - fun createUploadAuth( + suspend fun createUploadAuth( hash: HexKey, size: Long, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("upload", hash, size, alt, signer, createdAt, onReady) + ) = createAuth("upload", hash, size, alt, signer, createdAt) - private fun createAuth( + private suspend fun createAuth( type: String, hash: HexKey?, fileSize: Long?, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) { + ): BlossomAuthorizationEvent { val tags = listOfNotNull( arrayOf("t", type), @@ -87,7 +82,7 @@ class BlossomAuthorizationEvent( hash?.let { arrayOf("x", it) }, ) - signer.sign(createdAt, KIND, tags.toTypedArray(), alt, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), alt) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt similarity index 85% rename from quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt index 71df0ede5a..9e2c125702 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt @@ -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.quartz.blossom +package com.vitorpamplona.quartz.nipB7Blossom import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -64,13 +64,12 @@ class BlossomServersEvent( }.plusElement(AltTag.assemble(ALT)) .toTypedArray() - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: BlossomServersEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomServersEvent) -> Unit, - ) { + ): BlossomServersEvent { val tags = earlierVersion.tags .filter { it[0] != "server" } @@ -80,25 +79,19 @@ class BlossomServersEvent( }, ).toTypedArray() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + return signer.sign(createdAt, KIND, tags, earlierVersion.content) } - fun createFromScratch( + suspend fun createFromScratch( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomServersEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } + ) = create(relays, signer, createdAt) - fun create( + suspend fun create( servers: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomServersEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(servers), "", onReady) - } + ) = signer.sign(createdAt, KIND, createTagArray(servers), "") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt index 271175ddc4..db36dd7cef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt @@ -40,6 +40,8 @@ fun Array.startsWith(startsWith: Array): Boolean { return true } +fun Array.startsWithAny(startsWithList: List>): Boolean = startsWithList.any { startsWith(it) } + public inline fun Array.lastNotNullOfOrNull(transform: (T) -> R?): R? { for (index in this.indices.reversed()) { val result = transform(this[index]) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/GZip.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/GZip.kt new file mode 100644 index 0000000000..8bba32709d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/GZip.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class GZip { + companion object { + fun compress(content: String): ByteArray { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(content) } + return bos.toByteArray() + } + + fun decompress(content: ByteArray): String = GZIPInputStream(content.inputStream()).bufferedReader(Charsets.UTF_8).use { it.readText() } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt index 935942c912..270d66a5e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt @@ -43,6 +43,7 @@ object Hex { try { for (c in hex.indices) { + if (c < 0 || c > 255) return false if (hexToByte[hex[c].code] < 0) return false } } catch (e: IllegalArgumentException) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt index fd0a4bff48..bd61c05d19 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt @@ -26,6 +26,8 @@ import java.util.function.BiConsumer class LargeCache { private val cache = ConcurrentSkipListMap() + fun keys() = cache.keys + fun values() = cache.values fun get(key: K) = cache.get(key) @@ -36,6 +38,8 @@ class LargeCache { fun isEmpty() = cache.isEmpty() + fun clear() = cache.clear() + fun containsKey(key: K) = cache.containsKey(key) fun put( @@ -59,6 +63,19 @@ class LargeCache { } } + fun createIfAbsent( + key: K, + builder: (key: K) -> V, + ): Boolean { + val value = cache.get(key) + return if (value != null) { + false + } else { + val newObject = builder(key) + cache.putIfAbsent(key, newObject) == null + } + } + fun forEach(consumer: BiConsumer) { innerForEach(consumer) } @@ -153,14 +170,32 @@ class LargeCache { return runner.count } - private fun innerForEach(runner: BiConsumer) { - // val (value, elapsed) = - // measureTimedValue { - cache.forEach(runner) - // } - // println("LargeCache full loop $elapsed \t for $runner") + fun associate(transform: (K, V) -> Pair): Map { + val runner = BiAssociateCollector(size(), transform) + innerForEach(runner) + return runner.results + } - listOf(1, 2, 3).joinToString() + fun associateNotNull(transform: (K, V) -> Pair?): Map { + val runner = BiAssociateNotNullCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateWith(transform: (K, V) -> U?): Map { + val runner = BiAssociateWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNullWith(transform: (K, V) -> U): Map { + val runner = BiAssociateNotNullWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + private fun innerForEach(runner: BiConsumer) { + cache.forEach(runner) } fun joinToString( @@ -192,268 +227,338 @@ class LargeCache { buffer.append(postfix) return buffer.toString() } -} -fun interface BiFilter { - fun filter( - k: K, - v: V, - ): Boolean -} - -class BiFilterCollector( - val filter: BiFilter, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) { - results.add(v) - } + fun interface BiFilter { + fun filter( + k: K, + v: V, + ): Boolean } -} -class BiFilterUniqueCollector( - val filter: BiFilter, -) : BiConsumer { - var results: HashSet = HashSet() + class BiFilterCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: ArrayList = ArrayList() - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) { - results.add(v) - } - } -} - -fun interface BiMapper { - fun map( - k: K, - v: V, - ): R? -} - -class BiMapCollector( - val mapper: BiMapper, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.add(result) - } - } -} - -class BiMapUniqueCollector( - val mapper: BiMapper, -) : BiConsumer { - var results: HashSet = HashSet() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.add(result) - } - } -} - -class BiMapFlattenCollector( - val mapper: BiMapper?>, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.addAll(result) - } - } -} - -class BiMapFlattenUniqueCollector( - val mapper: BiMapper?>, -) : BiConsumer { - var results: HashSet = HashSet() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.addAll(result) - } - } -} - -fun interface BiNotNullMapper { - fun map( - k: K, - v: V, - ): R -} - -class BiNotNullMapCollector( - val mapper: BiNotNullMapper, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - results.add(mapper.map(k, v)) - } -} - -fun interface BiSumOf { - fun map( - k: K, - v: V, - ): Int -} - -class BiMaxOfCollector( - val filter: BiFilter, - val comparator: Comparator, -) : BiConsumer { - var maxK: K? = null - var maxV: V? = null - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) { - if (maxK == null || comparator.compare(v, maxV) > 0) { - maxK = k - maxV = v + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) } } } -} -class BiSumOfCollector( - val mapper: BiSumOf, -) : BiConsumer { - var sum = 0 + class BiFilterUniqueCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: HashSet = HashSet() - override fun accept( - k: K, - v: V, - ) { - sum += mapper.map(k, v) + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } } -} -fun interface BiSumOfLong { - fun map( - k: K, - v: V, - ): Long -} - -class BiSumOfLongCollector( - val mapper: BiSumOfLong, -) : BiConsumer { - var sum = 0L - - override fun accept( - k: K, - v: V, - ) { - sum += mapper.map(k, v) + fun interface BiMapper { + fun map( + k: K, + v: V, + ): R? } -} -class BiGroupByCollector( - val mapper: BiNotNullMapper, -) : BiConsumer { - var results = HashMap>() + fun interface BiMapperNotNull { + fun map( + k: K, + v: V, + ): R + } - override fun accept( - k: K, - v: V, - ) { - val group = mapper.map(k, v) + class BiMapCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() - val list = results[group] - if (list == null) { - val answer = ArrayList() - answer.add(v) - results[group] = answer - } else { - list.add(v) + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiAssociateCollector( + val size: Int, + val mapper: BiMapperNotNull>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + results.put(pair.first, pair.second) + } + } + + class BiAssociateNotNullCollector( + val size: Int, + val mapper: BiMapper?>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + if (pair != null) { + results.put(pair.first, pair.second) + } + } + } + + class BiAssociateWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + results.put(k, mapper.map(k, v)) + } + } + + class BiAssociateNotNullWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val newValue = mapper.map(k, v) + if (newValue != null) { + results.put(k, newValue) + } + } + } + + class BiMapUniqueCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiMapFlattenCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + class BiMapFlattenUniqueCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + fun interface BiNotNullMapper { + fun map( + k: K, + v: V, + ): R + } + + class BiNotNullMapCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + results.add(mapper.map(k, v)) + } + } + + fun interface BiSumOf { + fun map( + k: K, + v: V, + ): Int + } + + class BiMaxOfCollector( + val filter: BiFilter, + val comparator: Comparator, + ) : BiConsumer { + var maxK: K? = null + var maxV: V? = null + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + if (maxK == null || comparator.compare(v, maxV) > 0) { + maxK = k + maxV = v + } + } + } + } + + class BiSumOfCollector( + val mapper: BiSumOf, + ) : BiConsumer { + var sum = 0 + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + fun interface BiSumOfLong { + fun map( + k: K, + v: V, + ): Long + } + + class BiSumOfLongCollector( + val mapper: BiSumOfLong, + ) : BiConsumer { + var sum = 0L + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + class BiGroupByCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap>() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val list = results[group] + if (list == null) { + val answer = ArrayList() + answer.add(v) + results[group] = answer + } else { + list.add(v) + } + } + } + + class BiCountByGroupCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val count = results[group] + if (count == null) { + results[group] = 1 + } else { + results[group] = count + 1 + } + } + } + + class BiSumByGroupCollector( + val mapper: BiNotNullMapper, + val sumOf: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val sum = results[group] + if (sum == null) { + results[group] = sumOf.map(k, v) + } else { + results[group] = sum + sumOf.map(k, v) + } + } + } + + class BiCountIfCollector( + val filter: BiFilter, + ) : BiConsumer { + var count = 0 + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) count++ } } } - -class BiCountByGroupCollector( - val mapper: BiNotNullMapper, -) : BiConsumer { - var results = HashMap() - - override fun accept( - k: K, - v: V, - ) { - val group = mapper.map(k, v) - - val count = results[group] - if (count == null) { - results[group] = 1 - } else { - results[group] = count + 1 - } - } -} - -class BiSumByGroupCollector( - val mapper: BiNotNullMapper, - val sumOf: BiNotNullMapper, -) : BiConsumer { - var results = HashMap() - - override fun accept( - k: K, - v: V, - ) { - val group = mapper.map(k, v) - - val sum = results[group] - if (sum == null) { - results[group] = sumOf.map(k, v) - } else { - results[group] = sum + sumOf.map(k, v) - } - } -} - -class BiCountIfCollector( - val filter: BiFilter, -) : BiConsumer { - var count = 0 - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) count++ - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeSoftCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeSoftCache.kt new file mode 100644 index 0000000000..50f539bd69 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeSoftCache.kt @@ -0,0 +1,627 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentSkipListMap +import java.util.function.BiConsumer + +class LargeSoftCache { + private val cache = ConcurrentSkipListMap>() + + fun keys() = cache.keys + + fun get(key: K): V? { + val softRef = cache.get(key) + val value = softRef?.get() + + return if (value != null) { + value + } else { + cache.remove(key) + null + } + } + + fun remove(key: K) = cache.remove(key) + + fun size() = cache.size + + fun isEmpty() = cache.isEmpty() + + fun clear() = cache.clear() + + fun containsKey(key: K) = cache.containsKey(key) + + /** + * Puts an object into the cache with a specified key. + * The object is stored as a SoftReference. + * + * @param key The key to associate with the object. + * @param value The object to cache. + */ + fun put( + key: K, + value: V, + ) { + cache.put(key, WeakReference(value)) + } + + /** + * Retrieves an object from the cache using its key. + * Returns the object if it's still available (not garbage collected), + * otherwise returns null. If the object has been garbage collected, + * its entry is also removed from the cache. + * + * @param key The key of the object to retrieve. + * @return The cached object, or null if it's no longer available. + */ + fun getOrCreate( + key: K, + builder: (key: K) -> V, + ): V { + val softRef = cache.get(key) + val value = softRef?.get() + + return if (value != null) { + value + } else { + val newObject = builder(key) + cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject + } + } + + fun createIfAbsent( + key: K, + builder: (key: K) -> V, + ): Boolean { + val softRef = cache.get(key) + val value = softRef?.get() + return if (value != null) { + false + } else { + val newObject = builder(key) + cache.putIfAbsent(key, WeakReference(newObject)) == null + } + } + + /** + * Proactively cleans up the cache by removing entries whose weakly referenced + * objects have been garbage collected. While `get` handles cleanup on access, + * this method can be called periodically or when memory pressure is high. + */ + fun cleanUp() { + val keysToRemove = mutableListOf() + forEach { key, softRef -> + if (softRef == null) { + keysToRemove.add(key) + } + } + keysToRemove.forEach { key -> + cache.remove(key) + println("Cleaned up entry for key: $key (object was garbage collected)") + } + println("Cache cleanup completed. Remaining size: ${cache.size}") + } + + fun forEach(consumer: BiConsumer) { + innerForEach(consumer) + } + + fun filter(consumer: BiFilter): List { + val runner = BiFilterCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun filterIntoSet(consumer: BiFilter): Set { + val runner = BiFilterUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun map(consumer: BiNotNullMapper): List { + val runner = BiNotNullMapCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapNotNull(consumer: BiMapper): List { + val runner = BiMapCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapNotNullIntoSet(consumer: BiMapper): Set { + val runner = BiMapUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapFlatten(consumer: BiMapper?>): List { + val runner = BiMapFlattenCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapFlattenIntoSet(consumer: BiMapper?>): Set { + val runner = BiMapFlattenUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun maxOrNullOf( + filter: BiFilter, + comparator: Comparator, + ): V? { + val runner = BiMaxOfCollector(filter, comparator) + innerForEach(runner) + return runner.maxV + } + + fun sumOf(consumer: BiSumOf): Int { + val runner = BiSumOfCollector(consumer) + innerForEach(runner) + return runner.sum + } + + fun sumOfLong(consumer: BiSumOfLong): Long { + val runner = BiSumOfLongCollector(consumer) + innerForEach(runner) + return runner.sum + } + + fun groupBy(consumer: BiNotNullMapper): Map> { + val runner = BiGroupByCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun countByGroup(consumer: BiNotNullMapper): Map { + val runner = BiCountByGroupCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun sumByGroup( + groupMap: BiNotNullMapper, + sumOf: BiNotNullMapper, + ): Map { + val runner = BiSumByGroupCollector(groupMap, sumOf) + innerForEach(runner) + return runner.results + } + + fun count(consumer: BiFilter): Int { + val runner = BiCountIfCollector(consumer) + innerForEach(runner) + return runner.count + } + + fun associate(transform: (K, V) -> Pair): Map { + val runner = BiAssociateCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNull(transform: (K, V) -> Pair?): Map { + val runner = BiAssociateNotNullCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateWith(transform: (K, V) -> U?): Map { + val runner = BiAssociateWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNullWith(transform: (K, V) -> U): Map { + val runner = BiAssociateNotNullWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + private fun innerForEach(runner: BiConsumer) { + cache.forEach(BiConsumerWrapper(this, runner)) + } + + fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + truncated: CharSequence = "...", + transform: ((K, V) -> CharSequence)? = null, + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + forEach { key, value -> + val str = if (transform != null) transform(key, value) else "" + if (str.isNotEmpty()) { + if (++count > 1) buffer.append(separator) + if (limit < 0 || count <= limit) { + when { + transform != null -> buffer.append(str) + else -> buffer.append("$key $value") + } + } else { + return@forEach + } + } + } + if (limit >= 0 && count > limit) buffer.append(truncated) + buffer.append(postfix) + return buffer.toString() + } + + class BiConsumerWrapper( + val cache: LargeSoftCache, + val inner: BiConsumer, + ) : BiConsumer> { + override fun accept( + k: K, + ref: WeakReference, + ) { + val value = ref.get() + if (value == null) { + cache.remove(k) + } else { + inner.accept(k, value) + } + } + } + + fun interface BiFilter { + fun filter( + k: K, + v: V, + ): Boolean + } + + class BiFilterCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } + } + + class BiFilterUniqueCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } + } + + fun interface BiMapper { + fun map( + k: K, + v: V, + ): R? + } + + fun interface BiMapperNotNull { + fun map( + k: K, + v: V, + ): R + } + + class BiMapCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiAssociateCollector( + val size: Int, + val mapper: BiMapperNotNull>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + results.put(pair.first, pair.second) + } + } + + class BiAssociateNotNullCollector( + val size: Int, + val mapper: BiMapper?>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + if (pair != null) { + results.put(pair.first, pair.second) + } + } + } + + class BiAssociateWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + results.put(k, mapper.map(k, v)) + } + } + + class BiAssociateNotNullWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val newValue = mapper.map(k, v) + if (newValue != null) { + results.put(k, newValue) + } + } + } + + class BiMapUniqueCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiMapFlattenCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + class BiMapFlattenUniqueCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + fun interface BiNotNullMapper { + fun map( + k: K, + v: V, + ): R + } + + class BiNotNullMapCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + results.add(mapper.map(k, v)) + } + } + + fun interface BiSumOf { + fun map( + k: K, + v: V, + ): Int + } + + class BiMaxOfCollector( + val filter: BiFilter, + val comparator: Comparator, + ) : BiConsumer { + var maxK: K? = null + var maxV: V? = null + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + if (maxK == null || comparator.compare(v, maxV) > 0) { + maxK = k + maxV = v + } + } + } + } + + class BiSumOfCollector( + val mapper: BiSumOf, + ) : BiConsumer { + var sum = 0 + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + fun interface BiSumOfLong { + fun map( + k: K, + v: V, + ): Long + } + + class BiSumOfLongCollector( + val mapper: BiSumOfLong, + ) : BiConsumer { + var sum = 0L + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + class BiGroupByCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap>() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val list = results[group] + if (list == null) { + val answer = ArrayList() + answer.add(v) + results[group] = answer + } else { + list.add(v) + } + } + } + + class BiCountByGroupCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val count = results[group] + if (count == null) { + results[group] = 1 + } else { + results[group] = count + 1 + } + } + } + + class BiSumByGroupCollector( + val mapper: BiNotNullMapper, + val sumOf: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val sum = results[group] + if (sum == null) { + results[group] = sumOf.map(k, v) + } else { + results[group] = sum + sumOf.map(k, v) + } + } + } + + class BiCountIfCollector( + val filter: BiFilter, + ) : BiConsumer { + var count = 0 + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) count++ + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt index b8700c6069..6c327f4238 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt @@ -82,7 +82,7 @@ object LibSodiumInstance { messageBytes: ByteArray, nonce: ByteArray, key: ByteArray, - ): ByteArray? { + ): ByteArray { val cipher = ByteArray(messageBytes.size) val k2 = ByteArray(32) @@ -100,6 +100,6 @@ object LibSodiumInstance { k2, ) - return if (resultCode == 0) cipher else null + return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt new file mode 100644 index 0000000000..98d7db8e91 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +class MapOfSetBuilder { + val data = mutableMapOf>() + + fun add( + key: K, + value: V, + ) { + val set = data[key] + if (set == null) { + data.put(key, mutableSetOf(value)) + } else { + set.add(value) + } + } + + fun add( + key: K, + value: Set, + ) { + val set = data[key] + if (set == null) { + data.put(key, value.toMutableSet()) + } else { + set.addAll(value) + } + } + + fun add(map: Map>) { + map.forEach { + add(it.key, it.value) + } + } + + fun build(): Map> = data +} + +fun mapOfSet(init: MapOfSetBuilder.() -> Unit): Map> { + val data = MapOfSetBuilder() + data.init() + return data.build() +} + +fun merge(maps: List>>): Map> = + mapOfSet { + maps.forEach { map -> + add(map) + } + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt new file mode 100644 index 0000000000..b47f4250b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt @@ -0,0 +1,247 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resume + +/** + * Launches an async coroutine for each item, runs the + * function and waits for everybody to finsih + */ +suspend fun launchAndWaitAll( + items: List, + asyncFunc: suspend (T) -> Unit, +) { + coroutineScope { + val jobs = + items.map { next -> + async { + asyncFunc(next) + } + } + + // runs in parallel to avoid overcrowding Amber. + withTimeoutOrNull(15000) { + jobs.joinAll() + } + + async { + jobs.forEach { + it.cancel("Timeout") + } + } + } +} + +/** + * Runs the function and waits for 10 seconds for any result. + */ +suspend inline fun tryAndWait( + timeoutMillis: Long = 10000, + crossinline asyncFunc: (CancellableContinuation) -> Unit, +): T? = + withTimeoutOrNull(timeoutMillis) { + suspendCancellableCoroutine { continuation -> + asyncFunc(continuation) + } + } + +/** + * Runs an async coroutine for each one of the items, + * runs the request for that item, + * and gathers all the results in the output map. + */ +suspend fun collectSuccessfulOperationsReturning( + items: List, + runRequestFor: (T, (K) -> Unit) -> Unit, +): List { + val output: MutableList = mutableListOf() + + if (items.isEmpty()) { + return output + } + + launchAndWaitAll(items) { + val result = + tryAndWait { continuation -> + runRequestFor(it) { result: K -> continuation.resume(result) } + } + + if (result != null) { + output.add(result) + } + } + + return output +} + +/** + * Executes multiple suspending functions concurrently using `async` and attempts to wait for all of them + * to complete within a default 15-second timeout. + * + * If the timeout is reached, it returns the results of all tasks that successfully completed by then + * and cancels any tasks that are still running. Tasks that completed with an exception are not + * included in the returned list. + * + * @param tasks A list of suspending functions, each returning a value of type [T]. + * @return A list containing the results of tasks that successfully completed within the timeout. + */ +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun mapNotNullAsync( + items: List, + timeoutMillis: Long = 30000, + runRequestFor: suspend (T) -> K?, +): List { + if (items.isEmpty()) { + return emptyList() + } + + return coroutineScope { + // Launch all tasks asynchronously and get their Deferred handles. + val jobs = + items.map { item -> + async { + runRequestFor(item) + } + } + + // Use withTimeout to impose a 15-second limit on waiting for all deferreds. + // If all tasks complete within 15 seconds, awaitAll() will return their results, + // and this block will return those results. + withTimeoutOrNull(timeoutMillis) { + jobs.joinAll() + } + + async { + jobs.forEach { + it.cancel("Timeout") + } + } + + jobs.mapNotNull { + if (it.isCompleted) { + it.getCompleted() + } else { + null + } + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun mapNotNullAsyncSimple( + items: List, + runRequestFor: suspend (T) -> K?, +): List { + if (items.isEmpty()) { + return emptyList() + } + + return coroutineScope { + // Launch all tasks asynchronously and get their Deferred handles. + val jobs = + items.map { item -> + async { + runRequestFor(item) + } + } + + jobs.joinAll() + + jobs.mapNotNull { + if (it.isCompleted) { + it.getCompleted() + } else { + null + } + } + } +} + +/** + * Executes a mapping function asynchronously on each input in the list. + * Returns true as soon as the first mapping returns true, cancelling all other ongoing operations. + * + * @param inputs A list of input objects to process. + * @param mappingFunction A suspend function that takes an input object and returns a Boolean. + * @return True if any mapping function returns true, false otherwise. + */ +suspend fun anyAsync( + inputs: List, + timeoutMillis: Long = 30000, + mappingFunction: suspend (T) -> Boolean, +): Boolean = + coroutineScope { + // Create a list to hold all our deferred results + val deferredResults = + inputs.map { input -> + async { + // Each async block will execute the mapping function. + // If this coroutine gets cancelled, CancellationException will be thrown, + // and we'll catch it to ensure it doesn't propagate further. + try { + mappingFunction(input) + } catch (e: CancellationException) { + // When cancelled, we treat it as if it didn't return true + false + } + } + } + + // Use select to wait for the first deferred to complete with 'true' + val foundTrue = + withTimeoutOrNull(timeoutMillis) { + select { + deferredResults.forEach { deferred -> + // For each deferred, if it completes and its result is 'true', + // this branch of the select expression will be chosen. + deferred.onAwait { result -> + if (result) { + true // Return true from the select expression + } else { + // If a deferred completes with false, we don't want to + // immediately end the select, so we return false, which + // lets select continue waiting for other branches. + false + } + } + } + } + } + + // Once select returns (either with true or after all deferreds complete/are cancelled), + // cancel any remaining ongoing operations. + // If foundTrue is true, all other deferreds are implicitly cancelled by the select winning. + // If foundTrue is false, it means all completed with false or were cancelled. + deferredResults.forEach { it.cancel() } // Ensure all are cancelled. + + return@coroutineScope foundTrue == true + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt index 84b90b82ca..5063f34277 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt @@ -28,4 +28,10 @@ object RandomInstance { fun int(bound: Int = Int.MAX_VALUE) = randomizer.nextInt(bound) fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) } + + val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + + fun randomChar() = charPool[randomizer.nextInt(charPool.size)] + + fun randomChars(size: Int = 16) = String(CharArray(size) { randomChar() }) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index 13fc92809f..45d83bfc9e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.utils object TimeUtils { + const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE const val FIFTEEN_MINUTES = 15 * ONE_MINUTE @@ -33,6 +34,8 @@ object TimeUtils { fun now() = System.currentTimeMillis() / 1000 + fun tenSecondsFromNow() = now() + TEN_SECONDS + fun oneMinuteFromNow() = now() + ONE_MINUTE fun oneMinuteAgo() = now() - ONE_MINUTE diff --git a/quartz/src/test/java/android/util/Log.java b/quartz/src/test/java/android/util/Log.java new file mode 100644 index 0000000000..af85d0b47e --- /dev/null +++ b/quartz/src/test/java/android/util/Log.java @@ -0,0 +1,35 @@ +package android.util; + +public class Log { + public static int d(String tag, String msg) { + System.out.println("DEBUG: " + tag + ": " + msg); + return 0; + } + + public static int i(String tag, String msg) { + System.out.println("INFO: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg) { + System.out.println("WARN: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg, Throwable e) { + System.out.println("WARN: " + tag + ": " + msg); + e.printStackTrace(); + return 0; + } + + public static int e(String tag, String msg) { + System.out.println("ERROR: " + tag + ": " + msg); + return 0; + } + + public static int e(String tag, String msg, Throwable e) { + System.out.println("ERROR: " + tag + ": " + msg); + e.printStackTrace(); + return 0; + } +} \ No newline at end of file diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt index 341e4074c0..f58a4cf7da 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.hints import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.usedMemoryMb @@ -63,6 +64,7 @@ class HintIndexerTest { ?.readAllBytes() .toString() .split("\n") + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } val indexer by lazy { System.gc() @@ -119,31 +121,31 @@ class HintIndexerTest { @Test fun runProbExistingKeys() = assert99PercentSucess { - indexer.getKey(keys.random()).isNotEmpty() + indexer.hintsForKey(keys.random()).isNotEmpty() } @Test fun runProbNewKeys() = assert99PercentSucess { - indexer.getKey(RandomInstance.bytes(32)).isEmpty() + indexer.hintsForKey(RandomInstance.bytes(32)).isEmpty() } @Test fun runProbExistingEventIds() = assert99PercentSucess { - indexer.getEvent(eventIds.random()).isNotEmpty() + indexer.hintsForEvent(eventIds.random()).isNotEmpty() } @Test fun runProbNewEventIds() = assert99PercentSucess { - indexer.getEvent(RandomInstance.bytes(32)).isEmpty() + indexer.hintsForEvent(RandomInstance.bytes(32)).isEmpty() } @Test fun runProbExistingAddresses() = assert99PercentSucess { - indexer.getAddress(addresses.random()).isNotEmpty() + indexer.hintsForAddress(addresses.random()).isNotEmpty() } @Test @@ -156,6 +158,6 @@ class HintIndexerTest { randomChars(10), ) - indexer.getAddress(newAddress).isEmpty() + indexer.hintsForAddress(newAddress).isEmpty() } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt index 3d1fc91191..674d72bb02 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.jackson.EventManualSerializer -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.nsecToSigner import org.junit.Assert.assertEquals import org.junit.Test @@ -35,7 +35,7 @@ class UpdateMetadataTest { */ fun Event.toPrettyJson(): String { val obj = EventManualSerializer.assemble(id, pubKey, createdAt, kind, tags, content, sig) - return EventMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj) + return JsonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj) } @Test diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt new file mode 100644 index 0000000000..fd936867ac --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class RelayUrlFormatterTest { + @Test + fun format() { + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("nostr.mom")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("ws://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom/")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("https://nostr.mom/")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("http://nostr.mom/")?.url) + + assertEquals("wss://localhost:3030/", RelayUrlNormalizer.normalizeOrNull("wss://localhost:3030")?.url) + assertEquals("ws://localhost:3030/", RelayUrlNormalizer.normalizeOrNull("localhost:3030")?.url) + + assertEquals("wss://a.onion/", RelayUrlNormalizer.normalizeOrNull("wss://a.onion")?.url) + assertEquals("ws://a.onion/", RelayUrlNormalizer.normalizeOrNull("a.onion")?.url) + assertEquals("wss://a.onion/", RelayUrlNormalizer.normalizeOrNull("wss://a.onion/")?.url) + assertEquals("ws://a.onion/", RelayUrlNormalizer.normalizeOrNull("a.onion/")?.url) + + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom")?.url) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt new file mode 100644 index 0000000000..78e75b1016 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import junit.framework.TestCase +import org.junit.Test + +class EventDbQueryAssemblerTest { + val builder = EventIndexesModule(FullTextSearchModule()) + + val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" + val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" + + @Test + fun testEmpty() { + val sql = builder.planQuery(Filter()) + TestCase.assertEquals( + "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id", + sql, + ) + } + + @Test + fun testLimit() { + val sql = builder.planQuery(Filter(limit = 10)) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAllFearures() { + val sql = + builder.planQuery( + listOf( + Filter(limit = 10), + Filter(authors = listOf(key1), kinds = listOf(1, 1111), search = "keywords", limit = 100), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?)) AND (event_headers.pubkey = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 100 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 30) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testIdQuery() { + val sql = builder.planQuery(Filter(ids = listOf(key1))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = ?) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthors() { + val sql = builder.planQuery(Filter(authors = listOf(key1, key2))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.pubkey IN (?, ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthorsAndSearch() { + val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.pubkey IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndSearch() { + val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 12b91c6360..c4609ba934 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -20,13 +20,15 @@ */ package com.vitorpamplona.quartz.nip19Bech32 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -63,7 +65,7 @@ class NIP19ParserTest { @Test() fun uri_to_route_note() { val result = - Nip19Parser.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv")?.entity as? Note + Nip19Parser.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv")?.entity as? NNote assertNotNull(result) Assert.assertEquals( @@ -107,7 +109,7 @@ class NIP19ParserTest { Assert.assertNotNull(actual) Assert.assertTrue(actual?.entity is NProfile) Assert.assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", (actual?.entity as? NProfile)?.hex) - Assert.assertEquals("wss://vitor.nostr1.com/", (actual?.entity as? NProfile)?.relay?.first()) + Assert.assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), (actual?.entity as? NProfile)?.relay?.first()) } @Test() @@ -169,7 +171,7 @@ class NIP19ParserTest { "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", (result?.entity as? NAddress)?.aTag(), ) - assertEquals("wss://relay.damus.io", (result?.entity as? NAddress)?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), (result?.entity as? NAddress)?.relay?.get(0)) } @Test @@ -177,7 +179,7 @@ class NIP19ParserTest { val address = ATag.parse( "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - "wss://relay.damus.io", + "relay.damus.io", ) assertEquals(30023, address?.kind) assertEquals( @@ -185,9 +187,9 @@ class NIP19ParserTest { address?.pubKeyHex, ) assertEquals("89de7920", address?.dTag) - assertEquals("wss://relay.damus.io", address?.relay) + assertEquals("wss://relay.damus.io/", address?.relay?.url) assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + "naddr1qqyrswtyv5mnjv3sqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7q3q68nqgewzkamnyh53x0epqrftkv2pdh9gzr6558v4vetzr3w7uxfsxpqqqp65wmpxfdu", address?.toNAddr(), ) } @@ -229,10 +231,10 @@ class NIP19ParserTest { 30023, "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", "89de7920", - "wss://relay.damus.io", + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")!!, ) assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + "naddr1qqyrswtyv5mnjv3sqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7q3q68nqgewzkamnyh53x0epqrftkv2pdh9gzr6558v4vetzr3w7uxfsxpqqqp65wmpxfdu", address.toNAddr(), ) } @@ -298,7 +300,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result?.hex) - assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()) + assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()?.url) assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result?.author) assertEquals(1, result?.kind) } @@ -322,7 +324,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result?.hex) - assertEquals("wss://relay.westernbtc.com", result?.relay?.firstOrNull()) + assertEquals("wss://relay.westernbtc.com/", result?.relay?.firstOrNull()?.url) assertEquals(null, result?.author) assertEquals(null, result?.kind) } @@ -337,7 +339,10 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) - assertEquals("wss://nostr.mom", result?.relay?.get(0)) + assertEquals( + NormalizedRelayUrl("wss://nostr.mom/"), + result?.relay?.get(0), + ) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) assertEquals(1, result?.kind) } @@ -352,7 +357,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result?.relay?.get(0)) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -362,14 +367,14 @@ class NIP19ParserTest { val result = Nip19Parser .uriToRoute( - "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", + "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfsppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7mn0wd68ytn00p68ytnyv4mz7qg4waehxw309aex2mrp0yhxummnw3ezucn89uqjqamnwvaz7tmwdaehgu3wv45kuatwv3a8wctw0f5kwtnnwpskxef0qythwumn8ghj7un9d3shjtnwdaehgu3wvfskuep0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7dyp4wy", )?.entity as? NEvent assertNotNull(result) assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) assertEquals( - "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", - result?.relay?.joinToString(","), + "wss://nos.lol/,wss://nostr.oxtr.dev/,wss://relay.nostr.bg/,wss://nostr.einundzwanzig.space/,wss://relay.nostr.band/,wss://relay.damus.io/", + result?.relay?.joinToString(",") { it.url }, ) } @@ -383,7 +388,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay?.get(0)) + assertEquals("wss://relay.damus.io/", result?.relay?.get(0)?.url) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -422,10 +427,10 @@ class NIP19ParserTest { "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", 1, - "wss://relay.damus.io", + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), ) assertEquals( - "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqqqqsqvc0ku", nevent, ) } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index 3d94e0c4b4..9202392cbd 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.quartz.nip46RemoteSigner -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import org.junit.Test class BunkerRequestTest { @Test fun testBunkerRequestDeSerialization() { val requestJson = """{"id":"123","method":"sign_event","params":["{\"created_at\":1234,\"kind\":1,\"tags\":[],\"content\":\"This is an unsigned event.\"}"]}""" - val bunkerRequest = EventMapper.mapper.readValue(requestJson, BunkerRequest::class.java) + val bunkerRequest = JsonMapper.mapper.readValue(requestJson, BunkerRequest::class.java) assert(bunkerRequest is BunkerRequestSign) assert((bunkerRequest as BunkerRequestSign).event.kind == 1) diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt new file mode 100644 index 0000000000..b5c40011e7 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import junit.framework.TestCase.assertTrue +import org.junit.Test +import java.util.Arrays + +class TagArrayExt { + val tags = + arrayOf( + arrayOf("group", "test", "wss://nos.lol"), + arrayOf("group", "_", "wss://nos.lol"), + ) + + val expectedTags = + arrayOf( + arrayOf("group", "test", "wss://nos.lol"), + ) + + @Test + fun testRemove() { + assertTrue( + Arrays.deepEquals(expectedTags, tags.remove(arrayOf("group", "_", "wss://nos.lol"))), + ) + } + + @Test + fun testRemoveParsing() { + val removing = RoomId("_", RelayUrlNormalizer.normalize("wss://nos.lol")) + assertTrue( + Arrays.deepEquals( + expectedTags, + tags.removeParsing(RoomIdTag::parse, removing), + ), + ) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt index 1b40d70fa0..b90ade3333 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt @@ -20,29 +20,32 @@ */ package com.vitorpamplona.quartz.nip65RelayList +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import org.junit.Test class RelayListRecommendationProcessorTest { + fun norm(str: String) = RelayUrlNormalizer.normalizeOrNull(str)!! + val userList = mutableMapOf( - "User1" to mutableSetOf("wss://relay1.com", "wss://relay2.com", "wss://relay3.com"), - "User2" to mutableSetOf("wss://relay4.com", "wss://relay5.com", "wss://relay6.com"), - "User3" to mutableSetOf("wss://relay1.com", "wss://relay4.com", "wss://relay6.com"), - "User4" to mutableSetOf("wss://relay2.com", "wss://relay1.com", "wss://relay4.com"), + "User1" to mutableSetOf(norm("wss://relay1.com"), norm("wss://relay2.com"), norm("wss://relay3.com")), + "User2" to mutableSetOf(norm("wss://relay4.com"), norm("wss://relay5.com"), norm("wss://relay6.com")), + "User3" to mutableSetOf(norm("wss://relay1.com"), norm("wss://relay4.com"), norm("wss://relay6.com")), + "User4" to mutableSetOf(norm("wss://relay2.com"), norm("wss://relay1.com"), norm("wss://relay4.com")), ) @Test fun testTranspose() { assertEquals( mapOf( - "wss://relay1.com" to listOf("User1", "User3", "User4"), - "wss://relay2.com" to listOf("User1", "User4"), - "wss://relay3.com" to listOf("User1"), - "wss://relay4.com" to listOf("User2", "User3", "User4"), - "wss://relay5.com" to listOf("User2"), - "wss://relay6.com" to listOf("User2", "User3"), + norm("wss://relay1.com") to listOf("User1", "User3", "User4"), + norm("wss://relay2.com") to listOf("User1", "User4"), + norm("wss://relay3.com") to listOf("User1"), + norm("wss://relay4.com") to listOf("User2", "User3", "User4"), + norm("wss://relay5.com") to listOf("User2"), + norm("wss://relay6.com") to listOf("User2", "User3"), ).toString(), RelayListRecommendationProcessor.transpose(userList).toString(), ) @@ -54,7 +57,7 @@ class RelayListRecommendationProcessorTest { val rec1 = recommendations[0] - assertEquals("wss://relay1.com", rec1.url) + assertEquals("wss://relay1.com/", rec1.relay.url) assertEquals(true, rec1.requiredToNotMissEvents) assertTrue("User1" in rec1.users) assertTrue("User2" !in rec1.users) @@ -63,7 +66,7 @@ class RelayListRecommendationProcessorTest { val rec2 = recommendations[1] - assertEquals("wss://relay4.com", rec2.url) + assertEquals("wss://relay4.com/", rec2.relay.url) assertEquals(true, rec2.requiredToNotMissEvents) assertTrue("User1" !in rec2.users) assertTrue("User2" in rec2.users) @@ -72,7 +75,7 @@ class RelayListRecommendationProcessorTest { val rec3 = recommendations[2] - assertEquals("wss://relay2.com", rec3.url) + assertEquals("wss://relay2.com/", rec3.relay.url) assertEquals(false, rec3.requiredToNotMissEvents) assertTrue("User1" in rec3.users) assertTrue("User2" !in rec3.users) @@ -81,7 +84,7 @@ class RelayListRecommendationProcessorTest { val rec4 = recommendations[3] - assertEquals("wss://relay5.com", rec4.url) + assertEquals("wss://relay5.com/", rec4.relay.url) assertEquals(false, rec4.requiredToNotMissEvents) assertTrue("User1" !in rec4.users) assertTrue("User2" in rec4.users) diff --git a/settings.gradle b/settings.gradle index 4208f4fde2..0a0b52faa4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,7 +10,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() maven { - url "https://jitpack.io" + url = "https://jitpack.io" content { includeModule 'com.github.UnifiedPush', 'android-connector' } @@ -23,8 +23,8 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url "https://jitpack.io" } - maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/master" } + maven { url = "https://jitpack.io" } + maven { url = "https://raw.githubusercontent.com/guardianproject/gpmaven/master" } } }