diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index acd0939e9..ef98f7fa9 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 @@ -38,6 +39,7 @@ 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 @@ -49,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 @@ -57,12 +60,16 @@ import kotlin.random.Random class ImageUploadTesting { companion object { val accountSettings = AccountSettings(KeyPair()) + val signer = NostrSignerInternal(accountSettings.keyPair) + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val cache = LocalCache - val account = - Account( + val blossomServerListState = + BlossomServerListState( + signer = signer, + cache = cache, + scope = scope, settings = accountSettings, - signer = NostrSignerInternal(accountSettings.keyPair), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) } @@ -111,7 +118,7 @@ class ImageUploadTesting { sensitiveContent = null, serverBaseUrl = server.baseUrl, okHttpClient = { client }, - httpAuth = account::createBlossomUploadAuth, + httpAuth = blossomServerListState::createBlossomUploadAuth, context = InstrumentationRegistry.getInstrumentation().targetContext, ) @@ -139,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 @@ -183,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") }, @@ -225,6 +234,7 @@ class ImageUploadTesting { testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96)) } + @Ignore("Not Working anymore") @Test() fun testNostrBuild() = runBlocking { @@ -239,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 a2178a506..d03991147 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt @@ -30,12 +30,10 @@ 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,9 +47,9 @@ 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() }, ) @@ -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 8a028d15e..f1d881ce5 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -25,10 +25,13 @@ 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.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 @@ -37,86 +40,110 @@ 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() = @@ -139,17 +166,6 @@ class ThreadDualAxisChartAssemblerTest { null, ) - val keyPair = KeyPair() - val account = - Account( - settings = AccountSettings(keyPair = keyPair), - signer = NostrSignerInternal(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/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index bf885532f..1129c7773 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -43,10 +43,10 @@ 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.RelayProxyClientConnector -import com.vitorpamplona.amethyst.service.relayClient.RelaySpeedLogger 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.quartz.nip01Core.relay.client.NostrClient @@ -118,7 +118,7 @@ class Amethyst : Application() { val notifyCoordinator = NotifyCoordinator(client) // Authenticates with relays. - val authCoordinator = AuthCoordinator(client) + val authCoordinator = AuthCoordinator(client, applicationIOScope) val logger = if (isDebug) RelaySpeedLogger(client) else null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 075fd5c45..6fd951d95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -36,7 +36,6 @@ 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.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 @@ -49,13 +48,14 @@ 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.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +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 @@ -324,7 +324,7 @@ object LocalPreferences { ) putString( PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, - JsonMapper.mapper.writeValueAsString(settings.zapPaymentRequest?.denormalize()), + JsonMapper.mapper.writeValueAsString(settings.zapPaymentRequest.value?.denormalize()), ) if (settings.backupContactList != null) { putString( @@ -522,7 +522,6 @@ object LocalPreferences { "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e, ) - e.printStackTrace() null } } @@ -619,7 +618,7 @@ object LocalPreferences { defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer?.normalize(), + zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer?.normalize()), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, @@ -663,7 +662,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 } } @@ -678,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/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7460795c9..fa0a0b2a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -25,7 +25,9 @@ import androidx.compose.runtime.Stable 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.EphemeralChatListDecryptionCache import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState @@ -38,19 +40,30 @@ 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.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction -import com.vitorpamplona.amethyst.model.nip50Search.SearchRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.BlockPeopleListState -import com.vitorpamplona.amethyst.model.nip51Lists.BlockedRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.GeohashListState -import com.vitorpamplona.amethyst.model.nip51Lists.HashtagListState +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.MuteListState -import com.vitorpamplona.amethyst.model.nip51Lists.TrustedRelayListState +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.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.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState +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 @@ -59,13 +72,13 @@ 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.uploads.FileHeader import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent @@ -93,7 +106,6 @@ 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 @@ -108,10 +120,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer 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.taggedAddresses -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes @@ -123,6 +132,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references 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 @@ -140,33 +150,32 @@ 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.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -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.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.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +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 @@ -190,26 +199,19 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.math.BigDecimal import java.util.Base64 import java.util.Locale -import kotlin.coroutines.resume @OptIn(DelicateCoroutinesApi::class) @Stable @@ -227,25 +229,51 @@ class Account( val userMetadata = UserMetadataState(signer, cache, scope, settings) + val nip47SignerState = NwcSignerState(signer, cache, scope, settings) + val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) - val dmRelayList = DmRelayListState(signer, cache, scope, settings) - val searchRelayList = SearchRelayListState(signer, cache, scope, settings) - val privateStorageRelayList = PrivateStorageRelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) - val trustedRelayList = TrustedRelayListState(signer, cache, scope, settings) - val blockedRelayList = BlockedRelayListState(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 blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) + val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) val kind3FollowList = FollowListState(signer, cache, scope, settings) - val ephemeralChatList = EphemeralChatListState(signer, cache, scope, settings) - val publicChatList = PublicChatListState(signer, cache, scope, settings) - val communityList = CommunityListState(signer, cache, scope, settings) - val hashtagList = HashtagListState(signer, cache, scope, settings) - val geohashList = GeohashListState(signer, cache, scope, settings) - val muteList = MuteListState(signer, cache, scope, settings) - val blockPeopleList = BlockPeopleListState(signer, cache, scope) + 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) @@ -271,6 +299,21 @@ class Account( // 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 = LocalCache.getOrCreateChatroomList(signer.pubKey) + + val feedDecryptionCaches = + FeedDecryptionCaches( + peopleListCache = peopleListDecryptionCache, + muteListCache = muteListDecryptionCache, + communityListCache = communityListDecryptionCache, + hashtagCache = hashtagListDecryptionCache, + geohashCache = geohashListDecryptionCache, + ) + // App-ready Feeds val liveHomeFollowLists: StateFlow = FeedTopNavFilterState( @@ -279,6 +322,7 @@ class Account( locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, blockedRelays = blockedRelayList.flow, + caches = feedDecryptionCaches, signer = signer, scope = scope, ).flow @@ -292,6 +336,7 @@ class Account( locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, blockedRelays = blockedRelayList.flow, + caches = feedDecryptionCaches, signer = signer, scope = scope, ).flow @@ -305,6 +350,7 @@ class Account( locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, blockedRelays = blockedRelayList.flow, + caches = feedDecryptionCaches, signer = signer, scope = scope, ).flow @@ -318,6 +364,7 @@ class Account( locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, blockedRelays = blockedRelayList.flow, + caches = feedDecryptionCaches, signer = signer, scope = scope, ).flow @@ -338,36 +385,9 @@ class Account( val torRelayState = TorRelayState(trustedRelays, dmRelayList, settings, scope) - fun decryptPeopleList( - event: GeneralListEvent, - onReady: (Array>) -> Unit, - ) = event.privateTags(signer, onReady) - - @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) - } - fun isWriteable(): Boolean = settings.isWriteable() - fun updateWarnReports(warnReports: Boolean): Boolean { + suspend fun updateWarnReports(warnReports: Boolean): Boolean { if (settings.updateWarnReports(warnReports)) { sendNewAppSpecificData() return true @@ -375,7 +395,7 @@ 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) { hiddenUsers.resetTransientUsers() @@ -387,19 +407,19 @@ 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.Nip47URINorm?, @@ -415,18 +435,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, @@ -435,17 +455,13 @@ class Account( sendNewAppSpecificData() } - private fun sendNewAppSpecificData() { - if (!isWriteable()) return - - appSpecific.saveNewAppSpecificData(::sendMyPublicAndPrivateOutbox) - } + private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData()) suspend fun countFollowersOf(pubkey: HexKey): Int = cache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false } suspend fun followerCount(): Int = countFollowersOf(signer.pubKey) - fun sendNewUserMetadata( + suspend fun sendNewUserMetadata( name: String? = null, picture: String? = null, banner: String? = null, @@ -461,21 +477,23 @@ class Account( ) { if (!isWriteable()) return - userMetadata.sendNewUserMetadata( - name, - picture, - banner, - website, - pronouns, - about, - nip05, - lnAddress, - lnURL, - twitter, - mastodon, - github, - ::sendMyPublicAndPrivateOutbox, - ) + val userMetadataEvent = + userMetadata.sendNewUserMetadata( + name, + picture, + banner, + website, + pronouns, + about, + nip05, + lnAddress, + lnURL, + twitter, + mastodon, + github, + ) + + sendEverywhere(userMetadataEvent) } fun reactionTo( @@ -507,54 +525,24 @@ class Account( onPrivate = ::broadcastPrivately, ) - 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 - + ): LnZapRequestEvent { val relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()) - - note.event?.let { event -> - LnZapRequestEvent.create( - event, - relays = relays.mapTo(mutableSetOf()) { it.url }, - signer, - pollOption, - message, - zapType, - toUser?.pubkeyHex, - onReady = onReady, - ) - } - } - - 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) + return LnZapRequestEvent.create( + event, + relays = relays.mapTo(mutableSetOf()) { it.url }, + signer, + pollOption, + message, + zapType, + toUser?.pubkeyHex, + ) } suspend fun calculateIfNoteWasZappedByAccount( @@ -564,141 +552,92 @@ 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, - relay = nip47.relayUri, - ) - - Amethyst.instance.sources.nwc - .subscribe(filter) - - GlobalScope.launch(Dispatchers.IO) { - delay(60000) // waits 1 minute to complete payment. - Amethyst.instance.sources.nwc - .unsubscribe(filter) - } - - cache.consume(event, zappedNote, true, nip47.relayUri) { - it.response(signer) { onResponse(it) } - } - - client.send(event, setOf(nip47.relayUri)) - - onSent() - } - } + val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + client.send(event, setOf(relay)) } - fun createZapRequestFor( + suspend fun createZapRequestFor( user: User, message: String = "", zapType: LnZapEvent.ZapType, - onReady: (LnZapRequestEvent) -> Unit, - ) { + ): LnZapRequestEvent { val relays = nip65RelayList.inboxFlow.value + user.inboxRelays() - LnZapRequestEvent.create( - userHex = user.pubkeyHex, - relays = relays, - signer = signer, - message = message, - zapType = zapType, - ) { zapRequest -> - LocalCache.justConsumeMyOwnEvent(zapRequest) - onReady(zapRequest) - } + val zapRequest = + LnZapRequestEvent.create( + userHex = user.pubkeyHex, + relays = relays, + signer = signer, + message = message, + zapType = zapType, + ) + + cache.justConsumeMyOwnEvent(zapRequest) + return zapRequest } suspend fun report( note: Note, type: ReportType, content: String = "", - ) = ReportAction.report(note, type, content, userProfile(), signer, ::sendMyPublicAndPrivateOutbox) + ) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) suspend fun report( user: User, type: ReportType, - ) = ReportAction.report(user, type, userProfile(), signer, ::sendMyPublicAndPrivateOutbox) + ) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, userProfile(), signer)) - fun delete(note: Note) { + suspend fun delete(note: Note) { delete(listOf(note)) } - fun delete(notes: List) { + suspend fun delete(notes: List) { if (!isWriteable()) return 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. myNotes.chunked(200).forEach { chunkedList -> - signer.sign( - DeletionEvent.build(chunkedList.mapNotNull { it.event }), - ) { deletionEvent -> - val myRelayList = outboxRelays.flow.value.toMutableSet() - chunkedList.forEach { - myRelayList.addAll(it.relays) - } - - client.send(deletionEvent, myRelayList) - cache.justConsumeMyOwnEvent(deletionEvent) + 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) } } } - fun delete( + suspend fun delete( event: Event, additionalRelays: Set, ) { if (!isWriteable()) return if (event.pubKey != signer.pubKey) return - signer.sign( - DeletionEvent.build(listOf(event)), - ) { deletionEvent -> - client.send(deletionEvent, outboxRelays.flow.value + additionalRelays) - cache.justConsumeMyOwnEvent(deletionEvent) - } + 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, @@ -712,9 +651,9 @@ class Account( ) = blossomServers.createBlossomDeleteAuth(hash, alt) suspend fun boost(note: Note) { - RepostAction.repost(note, signer) { - client.send(it, computeMyReactionToNote(note, it)) - cache.justConsumeMyOwnEvent(it) + RepostAction.repost(note, signer)?.let { event -> + client.send(event, computeMyReactionToNote(note, event)) + cache.justConsumeMyOwnEvent(event) } } @@ -738,7 +677,7 @@ class Account( if (pubkey == userProfile().pubkeyHex) { notificationRelays.flow.value } else { - LocalCache + cache .getUserIfExists(pubkey) ?.inboxRelays() ?.ifEmpty { null } @@ -747,7 +686,7 @@ class Account( } } - val channelRelays = LocalCache.getAnyChannel(note)?.relays() ?: emptySet() + val channelRelays = cache.getAnyChannel(note)?.relays() ?: emptySet() val replyRelays = note.replyTo?.flatMapTo(mutableSetOf()) { @@ -794,7 +733,7 @@ class Account( if (pubkey == userProfile().pubkeyHex) { notificationRelays.flow.value } else { - LocalCache + cache .getUserIfExists(pubkey) ?.inboxRelays() ?.ifEmpty { null } @@ -802,7 +741,7 @@ class Account( ?: cache.relayHints.hintsForKey(pubkey).toSet() } - private fun computeRelaysForChannels(event: Event): Set = LocalCache.getAnyChannel(event)?.relays() ?: emptySet() + private fun computeRelaysForChannels(event: Event): Set = cache.getAnyChannel(event)?.relays() ?: emptySet() fun computeRelayListToBroadcast(event: Event): Set { if (event is MetadataEvent || event is AdvertisedRelayListEvent) { @@ -943,27 +882,24 @@ class Account( val otsResolver = otsResolver() - settings.pendingAttestations.value.forEach { pair -> - val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key, otsResolver) + settings.pendingAttestations.value.toList().forEach { (key, value) -> + val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver) if (otsState != null) { - val hint = cache.getNoteIfExists(pair.key)?.toEventHint() - + val hint = cache.getNoteIfExists(key)?.toEventHint() val template = if (hint != null) { OtsEvent.build(hint, otsState) } else { - OtsEvent.build(pair.key, otsState) + OtsEvent.build(key, otsState) } - signer.sign(template) { - cache.justConsumeMyOwnEvent(it) - client.send(it, computeRelayListToBroadcast(it)) + val otsEvent = signer.sign(template) - settings.pendingAttestations.update { - it - pair.key - } - } + cache.justConsumeMyOwnEvent(otsEvent) + client.send(otsEvent, computeRelayListToBroadcast(otsEvent)) + + settings.pendingAttestations.update { it - key } } } } @@ -983,63 +919,70 @@ class Account( settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver))) } - fun follow(user: User) = kind3FollowList.follow(user, this::sendMyPublicAndPrivateOutbox) + suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) - fun unfollow(user: User) = kind3FollowList.unfollow(user, this::sendMyPublicAndPrivateOutbox) + suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user)) - fun follow(channel: PublicChatChannel) = publicChatList.follow(channel, ::sendToPrivateOutboxAndLocal) + suspend fun follow(channel: PublicChatChannel) = sendMyPublicAndPrivateOutbox(publicChatList.follow(channel)) - fun unfollow(channel: PublicChatChannel) = publicChatList.unfollow(channel, ::sendToPrivateOutboxAndLocal) + suspend fun unfollow(channel: PublicChatChannel) = sendMyPublicAndPrivateOutbox(publicChatList.unfollow(channel)) - fun follow(channel: EphemeralChatChannel) = ephemeralChatList.follow(channel, ::sendToPrivateOutboxAndLocal) + suspend fun follow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.follow(channel)) - fun unfollow(channel: EphemeralChatChannel) = ephemeralChatList.unfollow(channel, ::sendToPrivateOutboxAndLocal) + suspend fun unfollow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.unfollow(channel)) - fun follow(community: AddressableNote) = communityList.follow(community, ::sendToPrivateOutboxAndLocal) + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) - fun unfollow(community: AddressableNote) = communityList.unfollow(community, ::sendToPrivateOutboxAndLocal) + suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) - fun followHashtag(tag: String) = hashtagList.follow(tag, ::sendMyPublicAndPrivateOutbox) + suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.follow(tag)) - fun unfollowHashtag(tag: String) = hashtagList.unfollow(tag, ::sendMyPublicAndPrivateOutbox) + suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) - fun followGeohash(geohash: String) = geohashList.follow(geohash, ::sendMyPublicAndPrivateOutbox) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) - fun unfollowGeohash(geohash: String) = geohashList.unfollow(geohash, ::sendMyPublicAndPrivateOutbox) + suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) - fun sendMyPublicAndPrivateOutbox(event: Event) { + fun sendMyPublicAndPrivateOutbox(event: Event?) { + if (event == null) return client.send(event, outboxRelays.flow.value) cache.justConsumeMyOwnEvent(event) } - fun createNip95( + fun sendMyPublicAndPrivateOutbox(events: List) { + events.forEach { + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) + } + } + + fun sendEverywhere(event: Event) { + client.send(event, outboxRelays.flow.value + client.relayStatusFlow().value.available) + cache.justConsumeMyOwnEvent(event) + } + + 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) + 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) } + 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), - ) + contentWarningReason?.let { contentWarning(contentWarningReason) } } - } + + val signedEvent = signer.sign(template) + return Pair(data, signedEvent) } fun consumeAndSendNip95( @@ -1089,40 +1032,10 @@ class Account( cache.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) { - hash(headerInfo.hash) - fileSize(headerInfo.size) - - headerInfo.mimeType?.let { mimeType(it) } - headerInfo.dim?.let { dimension(it) } - headerInfo.blurHash?.let { blurhash(it.blurhash) } - - originalHash?.let { originalHash(it) } - magnetUri?.let { magnet(it) } - - contentWarningReason?.let { contentWarning(contentWarningReason) } - }, - onReady, - ) - } - - fun sendAllAsOnePictureEvent( + suspend fun sendAllAsOnePictureEvent( urlHeaderInfo: Map, caption: String?, contentWarningReason: String?, - onReady: (Note) -> Unit, ) { val iMetas = urlHeaderInfo.map { @@ -1154,19 +1067,16 @@ class Account( contentWarningReason?.let { contentWarning(contentWarningReason) } } - signAndComputeBroadcast(template) { event -> - cache.getNoteIfExists(event.id)?.let { onReady(it) } - } + signAndComputeBroadcast(template) } - fun sendHeader( + suspend fun sendHeader( url: String, magnetUri: String?, headerInfo: FileHeader, alt: String?, contentWarningReason: String?, originalHash: String? = null, - onReady: (Note) -> Unit, ) { if (!isWriteable()) return @@ -1233,68 +1143,81 @@ class Account( } } - signAndComputeBroadcast(template) { event -> - cache.getNoteIfExists(event.id)?.let { onReady(it) } - } + signAndComputeBroadcast(template) } - fun signAndSendPrivately( + suspend fun signAndSendPrivately( template: EventTemplate, relayList: Set, ) { - signer.sign(template) { - cache.justConsumeMyOwnEvent(it) - client.send(it, relayList) - } + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + client.send(event, relayList) } - fun signAndSendPrivatelyOrBroadcast( + suspend fun signAndSendPrivatelyOrBroadcast( template: EventTemplate, relayList: (T) -> List?, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - cache.justConsumeMyOwnEvent(it) - val relays = relayList(it) - if (relays != null && relays.isNotEmpty()) { - client.send(it, relays.toSet()) - } else { - client.send(it, computeRelayListToBroadcast(it)) - } - onDone(it) + ): 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 } - fun signAndComputeBroadcast( + suspend fun signAndComputeBroadcast( template: EventTemplate, broadcast: List = emptyList(), - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { event -> - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } + ): 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) + val relayList = computeRelayListToBroadcast(note) - client.send(event, relayList) + client.send(event, relayList) - broadcast.forEach { client.send(it, relayList) } + broadcast.forEach { client.send(it, relayList) } - onDone(event) - } + return event } - fun signAndSend( + 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, ) { - signer.sign(template) { + 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) @@ -1302,36 +1225,7 @@ class Account( } } - fun signAndSend( - draftTag: String?, - template: EventTemplate, - relayList: Set, - broadcastNotes: List, - ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) - - fun signAndSend( - draftTag: String?, - template: EventTemplate, - relayList: Set, - broadcastNotes: Set, - ) { - if (draftTag != null) { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - signer.sign(template) { - cache.justConsumeMyOwnEvent(it) - client.send(it, relayList = relayList) - - broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } } - } - } - } - - fun signAndSendNIP04Message( + suspend fun signAndSendNIP04Message( draftTag: String?, template: EventTemplate, relayList: Set, @@ -1340,76 +1234,61 @@ class Account( 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) { - cache.justConsumeMyOwnEvent(it) - client.send(it, relayList) - } + val newEvent = signer.sign(template) + cache.justConsumeMyOwnEvent(newEvent) + client.send(newEvent, relayList) } } - fun signAndSendWithList( + suspend fun signAndSendWithList( draftTag: String?, template: EventTemplate, 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) { - cache.justConsumeMyOwnEvent(it) + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) - val relaySet = relayList.toSet() + val relaySet = relayList.toSet() - client.send(it, relayList = relaySet) - broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relaySet) } } - } + client.send(event, relayList = relaySet) + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relaySet) } } } } - fun sendTorrentComment( - draftTag: String?, - template: EventTemplate, - broadcastNotes: Set, - relayList: Set, - ) { - 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) cache.getAddressableNoteIfExists(key)?.let { note -> val noteEvent = note.event if (noteEvent is DraftEvent) { - noteEvent.createDeletedEvent(signer) { - client.send(it, outboxRelays.flow.value + note.relays) - cache.justConsumeMyOwnEvent(it) + val deletedDraftEvent = noteEvent.createDeletedEvent(signer) + client.send(deletedDraftEvent, outboxRelays.flow.value + note.relays) + cache.justConsumeMyOwnEvent(deletedDraftEvent) - delete(it, note.relays.toSet()) - } + delete(deletedDraftEvent, note.relays.toSet()) + draftsDecryptionCache.delete(noteEvent) } } } @@ -1430,7 +1309,7 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template, ::sendToPrivateOutboxAndLocal) + sendToPrivateOutboxAndLocal(signer.sign(template)) } suspend fun updateInteractiveStoryReadingState( @@ -1447,7 +1326,7 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template, ::sendToPrivateOutboxAndLocal) + sendToPrivateOutboxAndLocal(signer.sign(template)) } fun mapEntitiesToNotes(entities: List): List = @@ -1455,7 +1334,7 @@ class Account( when (it) { is NPub -> null is NProfile -> null - is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> cache.getOrCreateNote(it.hex) + 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()) @@ -1561,7 +1440,7 @@ class Account( signAndSendWithList(draftTag, template, relays, setOf(bounty)) } - fun sendEdit( + suspend fun sendEdit( message: String, originalNote: Note, notify: HexKey?, @@ -1572,24 +1451,25 @@ class Account( val idHex = originalNote.event?.id ?: return - TextNoteModificationEvent.create( - content = message, - eventId = idHex, - notify = notify, - summary = summary, - signer = signer, - ) { event -> - cache.justConsumeMyOwnEvent(event) - val note = cache.getOrCreateNote(event.id) - val relayList = computeRelayListToBroadcast(note) + val event = + TextNoteModificationEvent.create( + content = message, + eventId = idHex, + notify = notify, + summary = summary, + signer = signer, + ) - client.send(event, relayList = relayList) + cache.justConsumeMyOwnEvent(event) + val note = cache.getOrCreateNote(event.id) + val relayList = computeRelayListToBroadcast(note) - broadcast.forEach { client.send(it, relayList) } - } + client.send(event, relayList = relayList) + + broadcast.forEach { client.send(it, relayList) } } - fun sendPrivateMessage( + suspend fun sendPrivateMessage( message: String, toUser: PTag, replyingTo: Note? = null, @@ -1603,36 +1483,36 @@ 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()) } - val destinationRelays = cache.getOrCreateUser(toUser.pubKey).dmInboxRelays() + geohash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + emojis?.let { emojis(it) } + contentWarningReason?.let { contentWarning(contentWarningReason) } + } - signAndSendNIP04Message(draftTag, template, outboxRelays.flow.value + destinationRelays) - } + 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, ) { @@ -1642,16 +1522,14 @@ class Account( 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) } } @@ -1659,7 +1537,9 @@ class Account( sendToPrivateOutboxAndLocal(draftEvent) } - fun sendToPrivateOutboxAndLocal(event: Event) { + fun sendToPrivateOutboxAndLocal(event: Event?) { + if (event == null) return + val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value if (relayList.isNotEmpty()) { client.send(event, relayList.toSet()) @@ -1669,17 +1549,19 @@ class Account( cache.justConsumeMyOwnEvent(event) } - 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 -> - cache.justConsumeMyOwnEvent(rumor) - } + val gift = giftWrap.unwrapOrNull(signer) + if (gift is SealedRumorEvent) { + val rumor = gift.unsealOrNull(signer) + if (rumor != null) { + cache.justConsumeMyOwnEvent(rumor) } + } + if (gift != null) { cache.justConsumeMyOwnEvent(gift) } @@ -1700,20 +1582,20 @@ class Account( } } - fun createStatus(newStatus: String) = UserStatusAction.create(newStatus, signer, ::sendMyPublicAndPrivateOutbox) + suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) - fun updateStatus( + suspend fun updateStatus( oldStatus: AddressableNote, newStatus: String, - ) = UserStatusAction.update(oldStatus, newStatus, signer, ::sendMyPublicAndPrivateOutbox) + ) = sendMyPublicAndPrivateOutbox(UserStatusAction.update(oldStatus, newStatus, signer)) - fun deleteStatus(oldStatus: AddressableNote) = UserStatusAction.delete(oldStatus, signer, ::sendMyPublicAndPrivateOutbox) + suspend fun deleteStatus(oldStatus: AddressableNote) = sendMyPublicAndPrivateOutbox(UserStatusAction.delete(oldStatus, signer)) - fun removeEmojiPack(emojiPack: Note) = emoji.removeEmojiPack(emojiPack, ::sendMyPublicAndPrivateOutbox) + suspend fun removeEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.removeEmojiPack(emojiPack)) - fun addEmojiPack(emojiPack: Note) = emoji.addEmojiPack(emojiPack, ::sendMyPublicAndPrivateOutbox) + suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack)) - fun addToGallery( + suspend fun addToGallery( idHex: HexKey, url: String, relay: NormalizedRelayUrl?, @@ -1722,192 +1604,95 @@ class Account( 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) } - }, - ) { - client.send(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } + } + + val event = signer.sign(template) + sendMyPublicAndPrivateOutbox(event) } - fun removeFromGallery(note: Note) { + suspend fun removeFromGallery(note: Note) { delete(note) } - fun addBookmark( + fun isInPrivateBookmarks(note: Note) = bookmarkState.isInPrivateBookmarks(note) + + fun isInPublicBookmarks(note: Note) = bookmarkState.isInPublicBookmarks(note) + + suspend fun addBookmark( note: Note, isPrivate: Boolean, ) { if (!isWriteable()) return if (note.isDraft()) return - if (note is AddressableNote) { - BookmarkListEvent.addReplaceable( - userProfile().latestBookmarkList, - note.toATag(), - isPrivate, - signer, - ) { - client.send(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } - } else { - BookmarkListEvent.addEvent( - userProfile().latestBookmarkList, - note.idHex, - isPrivate, - signer, - ) { - client.send(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } - } + sendMyPublicAndPrivateOutbox(bookmarkState.addBookmark(note, isPrivate)) } - fun removeBookmark( + suspend fun removeBookmark( note: Note, isPrivate: Boolean, ) { if (!isWriteable()) return + if (note.isDraft()) return - val bookmarks = userProfile().latestBookmarkList ?: return - - if (note is AddressableNote) { - BookmarkListEvent.removeReplaceable( - bookmarks, - note.toATag(), - isPrivate, - signer, - ) { - client.send(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } - } else { - BookmarkListEvent.removeEvent( - bookmarks, - note.idHex, - isPrivate, - signer, - ) { - client.send(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } + val event = bookmarkState.removeBookmark(note, isPrivate) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) } } - fun sendAuthEvent( + suspend fun sendAuthEvent( relay: IRelayClient, challenge: String, ) { - RelayAuthEvent.create(relay.url, challenge, signer) { - client.sendIfExists(it, relay.url) - } + val auth = RelayAuthEvent.create(relay.url, challenge, signer) + client.sendIfExists(auth, relay.url) } - 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 hideWord(word: String) { + sendMyPublicAndPrivateOutbox(muteList.hideWord(word)) } - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - return if (note is AddressableNote) { - userProfile().latestBookmarkList?.isTaggedAddressableNote(note.idHex) == true - } else { - userProfile().latestBookmarkList?.isTaggedEvent(note.idHex) == true - } + suspend fun showWord(word: String) { + sendMyPublicAndPrivateOutbox(blockPeopleList.showWord(word)) + sendMyPublicAndPrivateOutbox(muteList.showWord(word)) } - fun hideWord(word: String) { - if (!isWriteable()) return - - muteList.hideWord(word, ::sendMyPublicAndPrivateOutbox) + suspend fun hideUser(pubkeyHex: HexKey) { + sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } - fun showWord(word: String) { - if (!isWriteable()) return - - blockPeopleList.showWord(word, ::sendMyPublicAndPrivateOutbox) - muteList.showWord(word, ::sendMyPublicAndPrivateOutbox) - } - - fun hideUser(pubkeyHex: HexKey) { - if (!isWriteable()) return - - muteList.hideUser(pubkeyHex, ::sendMyPublicAndPrivateOutbox) - } - - fun showUser(pubkeyHex: HexKey) { - if (!isWriteable()) return - - blockPeopleList.showUser(pubkeyHex, ::sendMyPublicAndPrivateOutbox) - muteList.showUser(pubkeyHex, ::sendMyPublicAndPrivateOutbox) + suspend fun showUser(pubkeyHex: HexKey) { + sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) + sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex)) hiddenUsers.showUser(pubkeyHex) } - fun requestDVMContentDiscovery( + suspend fun requestDVMContentDiscovery( dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, ) { val relays = nip65RelayList.inboxFlow.value.toSet() - NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) { - val relayList = - dvmPublicKey - .inboxRelays() - .ifEmpty { - LocalCache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + dvmPublicKey.relaysBeingUsed.keys - }.toSet() + val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) - cache.justConsumeMyOwnEvent(it) - onReady(it) - client.send(it, relayList) - } - } + 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) @@ -1917,11 +1702,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 } @@ -1930,48 +1715,31 @@ 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 } } suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = if (event.isPrivateZap()) { if (isWriteable()) { - tryAndWait { continuation -> - event.decryptPrivateZap(signer) { - continuation.resume(it) - } - } + privateZapsDecryptionCache.decryptPrivateZap(event) } else { null } @@ -2049,21 +1817,21 @@ class Account( ).toSet() } - fun saveDMRelayList(dmRelays: List) = dmRelayList.saveRelayList(dmRelays, ::sendMyPublicAndPrivateOutbox) + suspend fun saveDMRelayList(dmRelays: List) = sendEverywhere(dmRelayList.saveRelayList(dmRelays)) - fun savePrivateOutboxRelayList(relays: List) = privateStorageRelayList.saveRelayList(relays, ::sendMyPublicAndPrivateOutbox) + suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) - fun saveSearchRelayList(searchRelays: List) = searchRelayList.saveRelayList(searchRelays, ::sendMyPublicAndPrivateOutbox) + suspend fun saveSearchRelayList(searchRelays: List) = sendMyPublicAndPrivateOutbox(searchRelayList.saveRelayList(searchRelays)) - fun saveTrustedRelayList(trustedRelays: List) = trustedRelayList.saveRelayList(trustedRelays, ::sendMyPublicAndPrivateOutbox) + suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) - fun saveBlockedRelayList(blockedRelays: List) = blockedRelayList.saveRelayList(blockedRelays, ::sendMyPublicAndPrivateOutbox) + suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) - fun sendNip65RelayList(relays: List) = nip65RelayList.saveRelayList(relays, ::sendMyPublicAndPrivateOutbox) + suspend fun sendNip65RelayList(relays: List) = sendEverywhere(nip65RelayList.saveRelayList(relays)) - fun sendFileServersList(servers: List) = fileStorageServers.saveFileServersList(servers, ::sendMyPublicAndPrivateOutbox) + suspend fun sendFileServersList(servers: List) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers)) - fun sendBlossomServersList(servers: List) = blossomServers.saveBlossomServersList(servers, ::sendMyPublicAndPrivateOutbox) + suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) fun getAllPeopleLists(): List = getAllPeopleLists(signer.pubKey) @@ -2073,7 +1841,7 @@ class Account( 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 { @@ -2221,10 +1989,11 @@ 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 { cache.getPublicChatChannelIfExists(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 ") @@ -2236,7 +2005,9 @@ class Account( if (needsToUpgrade.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List") - publicChatList.follow(oldChannels, ::sendMyPublicAndPrivateOutbox) + runCatching { + sendMyPublicAndPrivateOutbox(publicChatList.follow(oldChannels)) + } } } @@ -2252,7 +2023,9 @@ class Account( if (needsToUpgrade.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List") - communityList.follow(oldCommunities, ::sendMyPublicAndPrivateOutbox) + runCatching { + sendMyPublicAndPrivateOutbox(communityList.follow(oldCommunities)) + } } } @@ -2268,14 +2041,15 @@ class Account( if (needsToUpgrade.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List") - hashtagList.follow(oldHashtags.toList(), ::sendMyPublicAndPrivateOutbox) + 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 communities ") + Log.d("DB UPGRADE", "Migrating List with ${oldGeohashes.size} old geohashes ") val existingGeohashes = geohashList.flow.value val needsToUpgrade = oldGeohashes.filter { it !in existingGeohashes } @@ -2284,7 +2058,9 @@ class Account( if (needsToUpgrade.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List") - geohashList.follow(oldGeohashes.toList(), ::sendMyPublicAndPrivateOutbox) + 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 58dc27e8b..0c0b467fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -27,26 +27,26 @@ 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.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.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +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 @@ -66,9 +66,9 @@ import java.util.Locale val DefaultChannels = listOf( // Anigma's Nostr - EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos), + ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos), // Amethyst's Group - EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos), + ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos), ) val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner) @@ -115,7 +115,7 @@ class AccountSettings( val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47WalletConnect.Nip47URINorm? = null, + var zapPaymentRequest: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -199,8 +199,8 @@ class AccountSettings( } fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean { - if (zapPaymentRequest != newServer) { - zapPaymentRequest = newServer + if (zapPaymentRequest.value != newServer) { + zapPaymentRequest.tryEmit(newServer) saveAccountSettings() return true } 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 5029f6044..16d1a7626 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -138,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/Channel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 7493edddd..7e6936fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -168,7 +168,7 @@ abstract class Channel { var creator: User? = null var updatedMetadataAt: Long = 0 val notes = LargeCache() - var lastNoteCreatedAt: Long = 0 + var lastNote: Note? = null private var relays = mapOf() @@ -220,8 +220,8 @@ abstract class Channel { ) { notes.put(note.idHex, note) - if ((note.createdAt() ?: 0) > lastNoteCreatedAt) { - lastNoteCreatedAt = note.createdAt() ?: 0 + if ((note.createdAt() ?: 0) > (lastNote?.createdAt() ?: 0)) { + lastNote = note } if (relay != null) { 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 a50c55a56..2bf690c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -28,12 +28,12 @@ import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag +import com.vitorpamplona.amethyst.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.ammolite.relays.BundledInsert 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 @@ -83,7 +83,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent 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 @@ -120,6 +119,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 @@ -127,16 +127,16 @@ 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.BlockedRelayListEvent -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.TrustedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +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.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 @@ -178,12 +178,11 @@ import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.LargeCache 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 @@ -210,11 +209,12 @@ object LocalCache : ILocalCache { val notes = LargeCache() val addressables = LargeCache() + val chatroomList = LargeCache() val publicChatChannels = LargeCache() val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() - val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) val relayHints = HintIndexer() @@ -223,8 +223,6 @@ object LocalCache : ILocalCache { val observablesByKindAndETag = ConcurrentHashMap>>(10) val observablesByKindAndAuthor = ConcurrentHashMap>>(10) - val onNewEvents = mutableListOf<(Note) -> Unit>() - fun observeETag( kind: Int, eventId: HexKey, @@ -321,6 +319,8 @@ object LocalCache : ILocalCache { fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) + fun getChatroomListIfExists(key: String): ChatroomList? = chatroomList.get(key) + fun getPublicChatChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key) fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key) @@ -400,20 +400,13 @@ object LocalCache : ILocalCache { } } - fun getOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = - publicChatChannels.getOrCreate(key) { - PublicChatChannel(key) - } + fun getOrCreateChatroomList(key: HexKey): ChatroomList = chatroomList.getOrCreate(key) { ChatroomList(key) } - fun getOrCreateLiveChannel(key: Address): LiveActivitiesChannel = - liveChatChannels.getOrCreate(key) { - LiveActivitiesChannel(key) - } + fun getOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = publicChatChannels.getOrCreate(key) { PublicChatChannel(key) } - fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = - ephemeralChannels.getOrCreate(key) { - EphemeralChatChannel(key) - } + fun getOrCreateLiveChannel(key: Address): LiveActivitiesChannel = liveChatChannels.getOrCreate(key) { LiveActivitiesChannel(key) } + + fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) } fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { if (isValidHex(key)) { @@ -521,19 +514,7 @@ object LocalCache : ILocalCache { event: BookmarkListEvent, 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 - } + ) = consumeBaseReplaceable(event, relay, wasVerified) fun formattedDateTime(timestamp: Long): String = Instant @@ -599,7 +580,7 @@ object LocalCache : ILocalCache { // Counts the replies replyTo.forEach { it.addReply(note) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } else { @@ -711,7 +692,7 @@ object LocalCache : ILocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, replyTo) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -756,7 +737,7 @@ object LocalCache : ILocalCache { note.loadEvent(event, author, replyTo) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -864,7 +845,7 @@ object LocalCache : ILocalCache { channel.updateChannelInfo(creator, event, event.createdAt) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1054,7 +1035,7 @@ object LocalCache : ILocalCache { author.flowSet?.statuses?.invalidateData() - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1085,7 +1066,7 @@ object LocalCache : ILocalCache { version.flowSet?.ots?.invalidateData() } - refreshObservers(version) + refreshNewNoteObservers(version) return true } @@ -1175,7 +1156,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 { @@ -1199,39 +1180,7 @@ object LocalCache : ILocalCache { event: PrivateDmEvent, 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 recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = computeReplyTo(event) - - note.loadEvent(event, author, repliesTo) - - if (recipient != null) { - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - - refreshObservers(note) - - return true - } - - return false - } + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: DeletionEvent, @@ -1300,7 +1249,7 @@ object LocalCache : ILocalCache { } } - refreshObservers(note) + refreshNewNoteObservers(note) return true } else { @@ -1350,26 +1299,7 @@ object LocalCache : ILocalCache { 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) @@ -1377,7 +1307,7 @@ object LocalCache : ILocalCache { deleteNote.clearFlow() - notes.remove(deleteNote.idHex) + refreshDeletedNoteObservers(deleteNote) } fun deleteWraps(event: WrappedEvent) { @@ -1389,6 +1319,7 @@ object LocalCache : ILocalCache { deleteWraps(noteEvent) } it.clearFlow() + refreshDeletedNoteObservers(it) } notes.remove(it.id) @@ -1418,7 +1349,7 @@ object LocalCache : ILocalCache { justConsumeAndUpdateIndexes(it, relay, false) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1448,7 +1379,7 @@ object LocalCache : ILocalCache { justConsumeAndUpdateIndexes(it, relay, false) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1483,7 +1414,7 @@ object LocalCache : ILocalCache { justConsumeAndUpdateIndexes(it, relay, false) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1509,7 +1440,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addReaction(note) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1553,7 +1484,7 @@ object LocalCache : ILocalCache { } } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1576,7 +1507,7 @@ object LocalCache : ILocalCache { oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) true } else { wasVerified @@ -1624,7 +1555,7 @@ object LocalCache : ILocalCache { oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) } return isVerified @@ -1709,7 +1640,6 @@ 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 { @@ -1733,7 +1663,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addZap(zapRequest, note) } mentions.forEach { it.addZap(zapRequest, note) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1761,7 +1691,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addZap(note, null) } mentions.forEach { it.addZap(note, null) } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1826,7 +1756,7 @@ object LocalCache : ILocalCache { } } - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1885,7 +1815,7 @@ object LocalCache : ILocalCache { note.loadEvent(eventNoData, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -1897,150 +1827,25 @@ object LocalCache : ILocalCache { event: ChatMessageEvent, 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: 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: 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: 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, @@ -2056,7 +1861,7 @@ object LocalCache : ILocalCache { zappedNote: Note?, wasVerified: Boolean, relay: NormalizedRelayUrl?, - onResponse: (LnZapPaymentResponseEvent) -> Unit, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -2075,7 +1880,7 @@ object LocalCache : ILocalCache { awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) - refreshObservers(note) + refreshNewNoteObservers(note) return true } @@ -2106,7 +1911,9 @@ object LocalCache : ILocalCache { requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } - responseCallback(event) + GlobalScope.launch(Dispatchers.Default) { + responseCallback(event) + } return true } @@ -2438,9 +2245,9 @@ object LocalCache : ILocalCache { pruneOldMessagesChannel(channel) } - users.forEach { _, user -> - user.privateChatrooms.values.map { - val toBeRemoved = it.pruneMessagesToTheLatestOnly() + chatroomList.forEach { userHex, room -> + room.chatrooms.map { key, chatroom -> + val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() @@ -2456,7 +2263,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.roomMessages.size} kept", ) } } @@ -2582,6 +2389,8 @@ object LocalCache : ILocalCache { note.clearFlow() notes.remove(note.idHex) + + refreshDeletedNoteObservers(note) } fun removeFromCache(nextToBeRemoved: List) { @@ -2665,11 +2474,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 { @@ -2680,7 +2492,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 { @@ -2702,165 +2514,12 @@ object LocalCache : ILocalCache { val note = Note(event.id) note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList()) relay?.let { note.addRelay(it) } - refreshObservers(note) + 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) - } - } - 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 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 EphemeralChatEvent -> { - draft.roomId()?.let { - getOrCreateEphemeralChannel(it).addNote(note, null) - } - } - is ChannelMessageEvent -> { - draft.channelId()?.let { channelId -> - checkGetOrCreatePublicChatChannel(channelId)?.addNote(note, null) - } - } - is LiveActivitiesChatMessageEvent -> { - draft.activityAddress()?.let { channelId -> - getOrCreateLiveChannel(channelId).addNote(note, null) - } - } - is TextNoteEvent -> { - val replyTo = computeReplyTo(draft) - val author = getOrCreateUser(draftWrap.pubKey) - note.loadEvent(draftWrap, author, replyTo) - replyTo.forEach { it.addReply(note) } - } - } - } - - 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 -> - getPublicChatChannelIfExists(channelId)?.removeNote(draftWrap) - } - } - is EphemeralChatEvent -> { - draft.roomId()?.let { - getEphemeralChatChannelIfExists(it)?.removeNote(draftWrap) - } - } - is LiveActivitiesChatMessageEvent -> { - draft.activityAddress()?.let { channelId -> - getLiveActivityChannelIfExists(channelId)?.removeNote(draftWrap) - } - } - is TextNoteEvent -> { - val replyTo = computeReplyTo(draft) - replyTo.forEach { it.removeReply(draftWrap) } - } - } - } - fun consume(nip19: Entity) { when (nip19) { is NSec -> getOrCreateUser(nip19.toPubKeyHex()) @@ -3122,7 +2781,7 @@ object LocalCache : ILocalCache { } } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() + Log.w("LocalCache", "Cannot consume ${event.kind}", e) false } @@ -3159,15 +2818,24 @@ 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) { + fun newNote(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } } + + fun removedNote(newNote: Note) { + bundler.invalidateList(newNote) { bundledNewNotes -> + _deletedEventBundles.emit(bundledNewNotes) + } + } } 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 4594fa340..5f5d3fbd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +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 @@ -35,7 +36,6 @@ 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.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.events.ETag @@ -55,6 +55,7 @@ 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.nip54Wiki.WikiNoteEvent @@ -71,14 +72,11 @@ 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 com.vitorpamplona.quartz.utils.tryAndWait 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 @Stable class AddressableNote( @@ -466,21 +464,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() + } } } } @@ -513,7 +503,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() @@ -529,12 +519,7 @@ open class Note( val result = anyAsync(parallelDecrypt) { pair -> - val result = - tryAndWait { continuation -> - pair.first.decryptPrivateZap(account.signer) { - continuation.resume(it) - } - } + val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first) result?.pubKey == user.pubkeyHex && (option == null || option == pair.second?.zappedPollOption()) } @@ -607,26 +592,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) @@ -634,27 +614,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 } } @@ -663,18 +641,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 { @@ -686,34 +665,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, ) } 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 2e7b27ece..414abd6bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -22,7 +22,6 @@ 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.toShortDisplay import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -35,11 +34,9 @@ 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 @@ -47,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 @@ -60,7 +56,6 @@ class User( var latestMetadata: MetadataEvent? = null var latestMetadataRelay: NormalizedRelayUrl? = null var latestContactList: ContactListEvent? = null - var latestBookmarkList: BookmarkListEvent? = null var reports = mapOf>() private set @@ -71,9 +66,6 @@ class User( var relaysBeingUsed = mapOf() private set - var privateChatrooms = mapOf() - private set - fun pubkey() = Hex.decode(pubkeyHex) fun pubkeyNpub() = pubkey().toNpub() @@ -124,13 +116,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 @@ -230,73 +215,6 @@ 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: NormalizedRelayUrl, eventTime: Long, @@ -343,12 +261,6 @@ 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, @@ -432,10 +344,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 = @@ -444,10 +354,8 @@ class UserFlowSet( follows.hasObservers() || followers.hasObservers() || reports.hasObservers() || - messages.hasObservers() || relayInfo.hasObservers() || zaps.hasObservers() || - bookmarks.hasObservers() || statuses.hasObservers() } 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 000000000..ca8e73549 --- /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 index 43af1fdf9..e4f28c081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt @@ -26,9 +26,9 @@ 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.experimental.edits.PrivateOutboxRelayListEvent 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 @@ -44,6 +44,7 @@ import kotlinx.coroutines.launch class PrivateStorageRelayListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: PrivateStorageRelayListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -55,9 +56,9 @@ class PrivateStorageRelayListState( fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent - fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { + suspend fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList - return event?.relays()?.toSet() ?: emptySet() + return event?.let { decryptionCache.relays(it) } ?: emptySet() } val flow = @@ -69,28 +70,22 @@ class PrivateStorageRelayListState( .stateIn( scope, SharingStarted.Eagerly, - normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()), + emptySet(), ) - fun saveRelayList( - relays: List, - onDone: (PrivateOutboxRelayListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun saveRelayList(relays: List): PrivateOutboxRelayListEvent { val relayListForPrivateOutbox = getPrivateOutboxRelayList() - if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) { + return if (relayListForPrivateOutbox != null) { PrivateOutboxRelayListEvent.updateRelayList( earlierVersion = relayListForPrivateOutbox, relays = relays, signer = signer, - onReady = onDone, ) } else { - PrivateOutboxRelayListEvent.createFromScratch( + PrivateOutboxRelayListEvent.create( relays = relays, signer = signer, - onReady = onDone, ) } } @@ -100,17 +95,15 @@ class PrivateStorageRelayListState( Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } scope.launch(Dispatchers.Default) { Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") - getPrivateOutboxRelayListFlow().collect { + getPrivateOutboxRelayListFlow().collect { noteState -> Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}") - (it.note.event as? PrivateOutboxRelayListEvent)?.let { + (noteState.note.event as? PrivateOutboxRelayListEvent)?.let { settings.updatePrivateHomeRelayList(it) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/LoginResultProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt similarity index 53% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/LoginResultProcessor.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt index 1396e3fd1..60ff2f80f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/LoginResultProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.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.quartz.nip55AndroidSigner.api.foreground.processors +package com.vitorpamplona.amethyst.model.emphChat -import android.content.Intent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.LoginResponse +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 LoginResultProcessor( - val onReady: (HexKey, String) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = LoginResponse.parse(intent) +class EphemeralChatListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.pubkey, foregroundResult.result.packageName) - } - } + 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 07d22c658..ebc38b8dc 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 @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -43,11 +42,11 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlin.coroutines.resume class EphemeralChatListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: EphemeralChatListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -59,17 +58,10 @@ class EphemeralChatListState( fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent - suspend fun ephemeralChatListWithBackup(note: Note): Set = - ephemeralChatList( - note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList, - ) - - suspend fun ephemeralChatList(event: EphemeralChatListEvent?): Set = - tryAndWait { continuation -> - event?.publicAndPrivateRoomIds(signer) { - continuation.resume(it) - } - } ?: emptySet() + 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> = @@ -85,46 +77,36 @@ class EphemeralChatListState( emptySet(), ) - fun follow( - channel: EphemeralChatChannel, - onDone: (EphemeralChatListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + 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, - ) { - if (!signer.isWriteable()) return + 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 } } @@ -133,17 +115,15 @@ class EphemeralChatListState( Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } scope.launch(Dispatchers.Default) { Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start") - getEphemeralChatListFlow().collect { + getEphemeralChatListFlow().collect { noteState -> Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}") - (it.note.event as? EphemeralChatListEvent)?.let { + (noteState.note.event as? EphemeralChatListEvent)?.let { settings.updateEphemeralChatListTo(it) } } 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 index dc4e9a2c9..0061c0385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -48,7 +48,7 @@ class UserMetadataState( fun getUserMetadataEvent(): MetadataEvent? = getUserMetadataUser().latestMetadata - fun sendNewUserMetadata( + suspend fun sendNewUserMetadata( name: String? = null, picture: String? = null, banner: String? = null, @@ -61,8 +61,7 @@ class UserMetadataState( twitter: String? = null, mastodon: String? = null, github: String? = null, - onDone: (MetadataEvent) -> Unit, - ) { + ): MetadataEvent { val latest = getUserMetadataEvent() val template = @@ -101,7 +100,7 @@ class UserMetadataState( ) } - signer.sign(template, onDone) + return signer.sign(template) } init { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt index bf6a4e37a..cb577d294 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt @@ -23,7 +23,7 @@ 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.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent 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 index 59bedaa1e..409f31e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt @@ -113,39 +113,31 @@ class FollowListState( ) } - fun follow( - user: User, - onDone: (ContactListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(user: User): ContactListEvent { val contactList = getFollowListEvent() - if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer, onReady = onDone) + 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, - onReady = onDone, ) } } - fun unfollow( - user: User, - onDone: (ContactListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun unfollow(user: User): ContactListEvent? { val contactList = getFollowListEvent() - if (contactList != null && contactList.tags.isNotEmpty()) { + return if (contactList != null && contactList.tags.isNotEmpty()) { ContactListEvent.unfollowUser( contactList, user.pubkeyHex, signer, - onReady = onDone, ) + } else { + null } } 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 index 00603939a..39ac9db84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt @@ -23,7 +23,7 @@ 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.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent 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 index d7be6fa97..08e3b4111 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt @@ -71,25 +71,18 @@ class DmRelayListState( emptySet(), ) - fun saveRelayList( - dmRelays: List, - onDone: (ChatMessageRelayListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - + suspend fun saveRelayList(dmRelays: List): ChatMessageRelayListEvent { val relayListForDMs = getDMRelayList() - if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { + return if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { ChatMessageRelayListEvent.updateRelayList( earlierVersion = relayListForDMs, relays = dmRelays, signer = signer, - onReady = onDone, ) } else { - ChatMessageRelayListEvent.createFromScratch( + ChatMessageRelayListEvent.create( relays = dmRelays, signer = signer, - onReady = onDone, ) } } 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 index 54271181b..4d5d1276d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt @@ -28,17 +28,15 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent class RepostAction { companion object { - fun repost( + suspend fun repost( note: Note, signer: NostrSigner, - onDone: (Event) -> Unit, - ) { - if (!signer.isWriteable()) return - val noteEvent = note.event ?: return + ): Event? { + val noteEvent = note.event ?: return null if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) { // has already bosted in the past 5mins - return + return null } val noteHint = note.relayHintUrl() @@ -51,7 +49,7 @@ class RepostAction { GenericRepostEvent.build(noteEvent, noteHint, authorHint) } - signer.sign(template, onDone) + 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 index 9174c13d3..bf580d23c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt @@ -38,7 +38,7 @@ class ReactionAction { by: User, signer: NostrSigner, onPublic: (ReactionEvent) -> Unit, - onPrivate: (NIP17Factory.Result) -> Unit, + onPrivate: suspend (NIP17Factory.Result) -> Unit, ) { if (!signer.isWriteable()) return @@ -55,14 +55,14 @@ class ReactionAction { val emojiUrl = EmojiUrlTag.decode(reaction) if (emojiUrl != null) { note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer, - ) { - onPrivate(it) - } + onPrivate( + NIP17Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = it, + to = users, + signer = signer, + ), + ) } return @@ -70,14 +70,14 @@ class ReactionAction { } note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer, - ) { - onPrivate(it) - } + onPrivate( + NIP17Factory().createReactionWithinGroup( + content = reaction, + originalNote = it, + to = users, + signer = signer, + ), + ) } return } else { @@ -87,10 +87,7 @@ class ReactionAction { note.event?.let { val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())) - signer.sign( - template, - onReady = onPublic, - ) + onPublic(signer.sign(template)) } return @@ -98,10 +95,7 @@ class ReactionAction { } note.toEventHint()?.let { - signer.sign( - ReactionEvent.build(reaction, it), - onReady = onPublic, - ) + onPublic(signer.sign(ReactionEvent.build(reaction, it))) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/DecryptZapResultProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt similarity index 53% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/DecryptZapResultProcessor.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt index 8f8cdd68e..a0a54ff93 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/DecryptZapResultProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.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.quartz.nip55AndroidSigner.api.foreground.processors +package com.vitorpamplona.amethyst.model.nip28PublicChats -import android.content.Intent -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DecryptZapResponse -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +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 DecryptZapResultProcessor( - val onReady: (LnZapPrivateEvent) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = DecryptZapResponse.parse(intent) +class PublicChatListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.privateEvent) - } - } + 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 109193c0a..16218792a 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 @@ -28,10 +28,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.PublicChatChannel 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.utils.tryAndWait +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -45,11 +44,11 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlin.coroutines.resume class PublicChatListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: PublicChatListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -61,20 +60,13 @@ class PublicChatListState( fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent - suspend fun publicChatListWithBackup(note: Note): Set = - publicChatList( - note.event as? ChannelListEvent ?: settings.backupChannelList, - ) - - suspend fun publicChatList(event: ChannelListEvent?): Set = - tryAndWait { continuation -> - event?.publicAndPrivateChannels(signer) { - continuation.resume(it) - } - } ?: emptySet() + 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 flow: StateFlow> = + val flow: StateFlow> = getChannelListFlow() .transformLatest { noteState -> emit(publicChatListWithBackup(noteState.note)) @@ -101,54 +93,34 @@ class PublicChatListState( emptySet(), ) - fun follow( - channel: PublicChatChannel, - onDone: (ChannelListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + 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, - ) { - if (!signer.isWriteable()) return + 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, - ) { - if (!signer.isWriteable()) return + 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 } } @@ -157,9 +129,7 @@ class PublicChatListState( Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } 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 e81567ae9..b40392a63 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 @@ -127,39 +127,29 @@ class EmojiPackState( emptyList(), ) - fun addEmojiPack( - emojiPack: Note, - onDone: (EmojiPackSelectionEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - + suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent { val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return + if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.") - val eventHint = emojiPack.toEventHint() ?: return + val eventHint = emojiPack.toEventHint() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.") val usersEmojiList = getEmojiPackSelection() - if (usersEmojiList == null) { + return if (usersEmojiList == null) { val template = EmojiPackSelectionEvent.build(listOf(eventHint)) - signer.sign(template, onDone) + signer.sign(template) } else { val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint) - signer.sign(template, onDone) + signer.sign(template) } } - fun removeEmojiPack( - emojiPack: Note, - onDone: (EmojiPackSelectionEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - - val usersEmojiList = getEmojiPackSelection() ?: return + 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 + if (emojiPackEvent !is EmojiPackEvent) return null val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent) - signer.sign(template, onDone) + 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 index 645d8a2c8..834e2437f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt @@ -28,44 +28,35 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent class UserStatusAction { companion object { - fun create( + suspend fun create( newStatus: String, signer: NostrSigner, - onDone: (StatusEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + ): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer) - StatusEvent.create(newStatus, "general", expiration = null, signer, onReady = onDone) - } - - fun update( + suspend fun update( oldStatus: AddressableNote, newStatus: String, signer: NostrSigner, - onDone: (StatusEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return + ): StatusEvent { + val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event") - StatusEvent.update(oldEvent, newStatus, signer, onReady = onDone) + return StatusEvent.update(oldEvent, newStatus, signer) } - fun delete( + suspend fun delete( oldStatus: AddressableNote, signer: NostrSigner, - onDone: (Event) -> Unit, - ) { - if (!signer.isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return + ): List { + val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event") - StatusEvent.clear(oldEvent, signer) { event -> - onDone(event) + val event = StatusEvent.clear(oldEvent, signer) + val deletion = signer.sign( DeletionEvent.buildForVersionOnly(listOf(event)), - onReady = onDone, ) - } + + 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 000000000..9444bedba --- /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 000000000..57069ef61 --- /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 index d0fd40434..ce359259b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -24,7 +24,9 @@ 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.PeopleListEvent +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 @@ -38,8 +40,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update class HiddenUsersState( - val muteList: StateFlow, - val blockList: StateFlow, + val muteList: StateFlow>, + val blockList: StateFlow>, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -61,14 +63,14 @@ class HiddenUsersState( } suspend fun assembleLiveHiddenUsers( - blockList: PeopleListEvent.UsersAndWords, - muteList: PeopleListEvent.UsersAndWords, + blockList: List, + muteList: List, transientHiddenUsers: Set, showSensitiveContent: Boolean?, ): LiveHiddenUsers = LiveHiddenUsers( - hiddenUsers = blockList.users + muteList.users, - hiddenWords = blockList.words + muteList.words, + 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt index e40ef0a77..2bf5a11cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt @@ -18,14 +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.amethyst.model.nip51Lists +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.PeopleListEvent -import com.vitorpamplona.quartz.utils.tryAndWait +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 @@ -34,11 +36,11 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlin.coroutines.resume class BlockPeopleListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: PeopleListDecryptionCache, val scope: CoroutineScope, ) { fun getBlockListAddress() = PeopleListEvent.createBlockAddress(signer.pubKey) @@ -49,17 +51,10 @@ class BlockPeopleListState( fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent - suspend fun blockListWithBackup(note: Note): PeopleListEvent.UsersAndWords = - blockList( - note.event as? PeopleListEvent, - ) - - suspend fun blockList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords = - tryAndWait { continuation -> - event?.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() + suspend fun blockListWithBackup(note: Note): List { + val event = note.event as? PeopleListEvent + return event?.let { decryptionCache.usersAndWords(it) } ?: emptyList() + } val flow = getBlockListFlow() @@ -69,88 +64,55 @@ class BlockPeopleListState( .stateIn( scope, SharingStarted.Eagerly, - PeopleListEvent.UsersAndWords(), + emptyList(), ) - fun hideUser( - pubkeyHex: String, - onDone: (PeopleListEvent) -> Unit, - ) { + suspend fun hideUser(pubkeyHex: String): PeopleListEvent { val blockList = getBlockList() - if (blockList != null) { - PeopleListEvent.addUser( + return if (blockList != null) { + PeopleListEvent.add( earlierVersion = blockList, - pubKeyHex = pubkeyHex, + person = UserTag(pubkeyHex), isPrivate = true, signer = signer, - onReady = onDone, ) } else { - PeopleListEvent.createListWithUser( + PeopleListEvent.create( name = PeopleListEvent.BLOCK_LIST_D_TAG, - pubKeyHex = pubkeyHex, + person = UserTag(pubkeyHex), isPrivate = true, signer = signer, - onReady = onDone, + dTag = PeopleListEvent.BLOCK_LIST_D_TAG, ) } } - fun showUser( - pubkeyHex: String, - onDone: (PeopleListEvent) -> Unit, - ) { + suspend fun showUser(pubkeyHex: String): PeopleListEvent? { val blockList = getBlockList() - if (blockList != null) { - PeopleListEvent.removeUser( + return if (blockList != null) { + PeopleListEvent.remove( earlierVersion = blockList, - pubKeyHex = pubkeyHex, + person = UserTag(pubkeyHex), signer = signer, - onReady = onDone, - ) - } - } - - fun hideWord( - word: String, - onDone: (PeopleListEvent) -> Unit, - ) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.addWord( - earlierVersion = blockList, - word = word, - isPrivate = true, - signer = signer, - onReady = onDone, ) } else { - PeopleListEvent.createListWithWord( - name = PeopleListEvent.BLOCK_LIST_D_TAG, - word = word, - isPrivate = true, - signer = signer, - onReady = onDone, - ) + null } } - fun showWord( - word: String, - onDone: (PeopleListEvent) -> Unit, - ) { + suspend fun showWord(word: String): PeopleListEvent? { val blockList = getBlockList() - if (blockList != null) { - PeopleListEvent.removeWord( + return if (blockList != null) { + PeopleListEvent.remove( earlierVersion = blockList, - word = word, + person = WordTag(word), signer = signer, - onReady = onDone, ) + } 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 000000000..23d5ed4b3 --- /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 000000000..a3f3df679 --- /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/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt index b89795525..237e89216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.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.model.nip51Lists +package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays import android.util.Log import com.vitorpamplona.amethyst.model.AccountSettings @@ -28,7 +28,8 @@ 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.BlockedRelayListEvent +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 @@ -44,10 +45,11 @@ 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 getBlockedRelayListAddress() = BlockedRelayListEvent.Companion.createAddress(signer.pubKey) fun getBlockedRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getBlockedRelayListAddress()) @@ -55,9 +57,9 @@ class BlockedRelayListState( fun getBlockedRelayList(): BlockedRelayListEvent? = getBlockedRelayListNote().event as? BlockedRelayListEvent - fun normalizeBlockedRelayListWithBackup(note: Note): Set { + suspend fun normalizeBlockedRelayListWithBackup(note: Note): Set { val event = note.event as? BlockedRelayListEvent ?: settings.backupBlockedRelayList - return event?.relays()?.toSet() ?: emptySet() + return event?.let { decryptionCache.relays(it) } ?: emptySet() } val flow = @@ -67,29 +69,24 @@ class BlockedRelayListState( .flowOn(Dispatchers.Default) .stateIn( scope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, emptySet(), ) - fun saveRelayList( - blockedRelays: List, - onDone: (BlockedRelayListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun saveRelayList(blockedRelays: List): BlockedRelayListEvent { + if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException() val relayListForBlocked = getBlockedRelayList() - if (relayListForBlocked != null && relayListForBlocked.tags.isNotEmpty()) { - BlockedRelayListEvent.updateRelayList( + return if (relayListForBlocked != null && relayListForBlocked.tags.isNotEmpty()) { + BlockedRelayListEvent.Companion.updateRelayList( earlierVersion = relayListForBlocked, relays = blockedRelays, signer = signer, - onReady = onDone, ) } else { - BlockedRelayListEvent.createFromScratch( + BlockedRelayListEvent.Companion.create( relays = blockedRelays, signer = signer, - onReady = onDone, ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/NewResultProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt similarity index 86% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/NewResultProcessor.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt index c7281842c..25f818e72 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/NewResultProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt @@ -18,10 +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.quartz.nip55AndroidSigner.api.foreground +package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists -import android.content.Intent +data class GeohashListCard( + val relays: List, +) -interface NewResultProcessor { - fun consume(intent: Intent) -} +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 000000000..2576cbc12 --- /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/GeohashListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/GeohashListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt index 096a55fca..a11c07130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/GeohashListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.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.model.nip51Lists +package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists import android.util.Log import com.vitorpamplona.amethyst.model.AccountSettings @@ -27,8 +27,7 @@ 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.locations.GeohashListEvent -import com.vitorpamplona.quartz.utils.tryAndWait +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -41,11 +40,11 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlin.coroutines.resume class GeohashListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: GeohashListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -57,17 +56,10 @@ class GeohashListState( fun getGeohashList(): GeohashListEvent? = getGeohashListNote().event as? GeohashListEvent - suspend fun geohashListWithBackup(note: Note): Set = - geohashList( - note.event as? GeohashListEvent ?: settings.backupGeohashList, - ) - - suspend fun geohashList(event: GeohashListEvent?): Set = - tryAndWait { continuation -> - event?.publicAndPrivateGeohash(signer) { - continuation.resume(it) - } - } ?: emptySet() + 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> = @@ -83,43 +75,33 @@ class GeohashListState( emptySet(), ) - fun follow( - geohashs: List, - onDone: (GeohashListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(geohashes: List): GeohashListEvent { val geohashList = getGeohashList() - if (geohashList == null) { - GeohashListEvent.createGeohashs(geohashs, true, signer, onReady = onDone) + return if (geohashList == null) { + GeohashListEvent.create(geohashes, true, signer) } else { - GeohashListEvent.addGeohashs(geohashList, geohashs, true, signer, onReady = onDone) + GeohashListEvent.add(geohashList, geohashes, true, signer) } } - fun follow( - geohash: String, - onDone: (GeohashListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(geohash: String): GeohashListEvent { val geohashList = getGeohashList() - if (geohashList == null) { - GeohashListEvent.createGeohash(geohash, true, signer, onReady = onDone) + return if (geohashList == null) { + GeohashListEvent.create(geohash, true, signer) } else { - GeohashListEvent.addGeohash(geohashList, geohash, true, signer, onReady = onDone) + GeohashListEvent.add(geohashList, geohash, true, signer) } } - fun unfollow( - geohash: String, - onDone: (GeohashListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun unfollow(geohash: String): GeohashListEvent? { val geohashList = getGeohashList() - if (geohashList != null) { - GeohashListEvent.removeGeohash(geohashList, geohash, signer, onReady = onDone) + return if (geohashList != null) { + GeohashListEvent.remove(geohashList, geohash, signer) + } else { + null } } @@ -128,17 +110,15 @@ class GeohashListState( Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } scope.launch(Dispatchers.Default) { Log.d("AccountRegisterObservers", "Geohash List Collector Start") - getGeohashListFlow().collect { + getGeohashListFlow().collect { noteState -> Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}") - (it.note.event as? GeohashListEvent)?.let { + (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 000000000..6e73b068a --- /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/HashtagListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HashtagListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt index acf863a10..adf99d037 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HashtagListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.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.model.nip51Lists +package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists import android.util.Log import com.vitorpamplona.amethyst.model.AccountSettings @@ -27,8 +27,7 @@ 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.interests.HashtagListEvent -import com.vitorpamplona.quartz.utils.tryAndWait +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -41,15 +40,15 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlin.coroutines.resume class HashtagListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: HashtagListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { - fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey) + fun getHashtagListAddress() = HashtagListEvent.Companion.createAddress(signer.pubKey) fun getHashtagListNote(): AddressableNote = cache.getOrCreateAddressableNote(getHashtagListAddress()) @@ -57,17 +56,10 @@ class HashtagListState( fun getHashtagList(): HashtagListEvent? = getHashtagListNote().event as? HashtagListEvent - suspend fun hashtagListWithBackup(note: Note): Set = - hashtagList( - note.event as? HashtagListEvent ?: settings.backupHashtagList, - ) - - suspend fun hashtagList(event: HashtagListEvent?): Set = - tryAndWait { continuation -> - event?.publicAndPrivateHashtag(signer) { - continuation.resume(it) - } - } ?: emptySet() + 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> = @@ -79,47 +71,37 @@ class HashtagListState( }.flowOn(Dispatchers.Default) .stateIn( scope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, emptySet(), ) - fun follow( - hashtags: List, - onDone: (HashtagListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(hashtags: List): HashtagListEvent { val hashtagList = getHashtagList() - if (hashtagList == null) { - HashtagListEvent.createHashtags(hashtags, true, signer, onReady = onDone) + return if (hashtagList == null) { + HashtagListEvent.Companion.create(hashtags, true, signer) } else { - HashtagListEvent.addHashtags(hashtagList, hashtags, true, signer, onReady = onDone) + HashtagListEvent.Companion.add(hashtagList, hashtags, true, signer) } } - fun follow( - hashtag: String, - onDone: (HashtagListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(hashtag: String): HashtagListEvent { val hashtagList = getHashtagList() - if (hashtagList == null) { - HashtagListEvent.createHashtag(hashtag, true, signer, onReady = onDone) + return if (hashtagList == null) { + HashtagListEvent.Companion.create(hashtag, true, signer) } else { - HashtagListEvent.addHashtag(hashtagList, hashtag, true, signer, onReady = onDone) + HashtagListEvent.Companion.add(hashtagList, hashtag, true, signer) } } - fun unfollow( - hashtag: String, - onDone: (HashtagListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun unfollow(hashtag: String): HashtagListEvent? { val hashtagList = getHashtagList() - if (hashtagList != null) { - HashtagListEvent.removeHashtag(hashtagList, hashtag, signer, onReady = onDone) + return if (hashtagList != null) { + HashtagListEvent.Companion.remove(hashtagList, hashtag, signer) + } else { + null } } @@ -128,9 +110,7 @@ class HashtagListState( Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } 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 000000000..90314fc4a --- /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/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/MuteListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index c79549def..e284cc024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.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.model.nip51Lists +package com.vitorpamplona.amethyst.model.nip51Lists.muteList import android.util.Log import com.vitorpamplona.amethyst.model.AccountSettings @@ -26,9 +26,10 @@ 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.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.utils.tryAndWait +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 @@ -40,11 +41,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlin.coroutines.resume class MuteListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: MuteListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -56,17 +57,10 @@ class MuteListState( fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent - suspend fun muteListWithBackup(note: Note): PeopleListEvent.UsersAndWords = - muteList( - note.event as? MuteListEvent ?: settings.backupMuteList, - ) - - suspend fun muteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords = - tryAndWait { continuation -> - event?.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() + suspend fun muteListWithBackup(note: Note): List { + val event = note.event as? MuteListEvent ?: settings.backupMuteList + return event?.let { decryptionCache.mutedUsersAndWords(it) } ?: emptyList() + } val flow = getMuteListFlow() @@ -76,86 +70,72 @@ class MuteListState( .stateIn( scope, SharingStarted.Eagerly, - PeopleListEvent.UsersAndWords(), + emptyList(), ) - fun hideUser( - pubkeyHex: String, - onDone: (MuteListEvent) -> Unit, - ) { + suspend fun hideUser(pubkeyHex: String): MuteListEvent { val muteList = getMuteList() - if (muteList != null) { - MuteListEvent.addUser( + return if (muteList != null) { + MuteListEvent.add( earlierVersion = muteList, - pubKeyHex = pubkeyHex, + mute = UserTag(pubkeyHex), isPrivate = true, signer = signer, - onReady = onDone, ) } else { - MuteListEvent.createListWithUser( - pubKeyHex = pubkeyHex, + MuteListEvent.create( + mute = UserTag(pubkeyHex), isPrivate = true, signer = signer, - onReady = onDone, ) } } - fun showUser( - pubkeyHex: String, - onDone: (MuteListEvent) -> Unit, - ) { + suspend fun showUser(pubkeyHex: String): MuteListEvent? { val muteList = getMuteList() - if (muteList != null) { - MuteListEvent.removeUser( + return if (muteList != null) { + MuteListEvent.remove( earlierVersion = muteList, - pubKeyHex = pubkeyHex, + mute = UserTag(pubkeyHex), signer = signer, - onReady = onDone, - ) - } - } - - fun hideWord( - word: String, - onDone: (MuteListEvent) -> Unit, - ) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer, - onReady = onDone, ) } else { - MuteListEvent.createListWithWord( - word = word, + 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, - onReady = onDone, ) } } - fun showWord( - word: String, - onDone: (MuteListEvent) -> Unit, - ) { + suspend fun showWord(word: String): MuteListEvent? { val muteList = getMuteList() - if (muteList != null) { - MuteListEvent.removeWord( + return if (muteList != null) { + MuteListEvent.remove( earlierVersion = muteList, - word = word, + mute = WordTag(word), signer = signer, - onReady = onDone, ) + } else { + null } } @@ -164,9 +144,7 @@ class MuteListState( Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } 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 000000000..c8735fbda --- /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 000000000..bf12514c4 --- /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 000000000..bf88a3752 --- /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/nip50Search/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip50Search/SearchRelayListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 5fc320215..3c5f2a25f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip50Search/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.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.model.nip50Search +package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays import android.util.Log import com.vitorpamplona.amethyst.model.AccountSettings @@ -45,10 +45,11 @@ import kotlinx.coroutines.launch class SearchRelayListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: SearchRelayListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { - fun getSearchRelayListAddress() = SearchRelayListEvent.createAddress(signer.pubKey) + fun getSearchRelayListAddress() = SearchRelayListEvent.Companion.createAddress(signer.pubKey) fun getSearchRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getSearchRelayListAddress()) @@ -56,9 +57,9 @@ class SearchRelayListState( fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent - fun normalizeSearchRelayListWithBackup(note: Note): Set { + suspend fun normalizeSearchRelayListWithBackup(note: Note): Set { val event = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList - return event?.relays()?.toSet() ?: DefaultSearchRelayList + return event?.let { decryptionCache.relays(it) } ?: DefaultSearchRelayList } val flow = @@ -68,29 +69,23 @@ class SearchRelayListState( .flowOn(Dispatchers.Default) .stateIn( scope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, emptySet(), ) - fun saveRelayList( - searchRelays: List, - onDone: (SearchRelayListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun saveRelayList(searchRelays: List): SearchRelayListEvent { val relayListForSearch = getSearchRelayList() - if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { - SearchRelayListEvent.updateRelayList( + return if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { + SearchRelayListEvent.Companion.updateRelayList( earlierVersion = relayListForSearch, relays = searchRelays, signer = signer, - onReady = onDone, ) } else { - SearchRelayListEvent.createFromScratch( + SearchRelayListEvent.Companion.create( relays = searchRelays, signer = signer, - onReady = onDone, ) } } 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 000000000..276b47abb --- /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/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/TrustedRelayListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index f1137b7bb..d5bd69235 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.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.model.nip51Lists +package com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays import android.util.Log import com.vitorpamplona.amethyst.model.AccountSettings @@ -28,7 +28,7 @@ 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.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -44,20 +44,21 @@ import kotlinx.coroutines.launch class TrustedRelayListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: TrustedRelayListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { - fun getTrustedRelayListAddress() = TrustedRelayListEvent.createAddress(signer.pubKey) + fun getTrustedRelayListAddress() = TrustedRelayListEvent.Companion.createAddress(signer.pubKey) - fun getTrustedRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getTrustedRelayListAddress()) + fun getTrustedRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getTrustedRelayListAddress()) fun getTrustedRelayListFlow(): StateFlow = getTrustedRelayListNote().flow().metadata.stateFlow fun getTrustedRelayList(): TrustedRelayListEvent? = getTrustedRelayListNote().event as? TrustedRelayListEvent - fun normalizeTrustedRelayListWithBackup(note: Note): Set { + suspend fun normalizeTrustedRelayListWithBackup(note: Note): Set { val event = note.event as? TrustedRelayListEvent ?: settings.backupTrustedRelayList - return event?.relays()?.toSet() ?: emptySet() + return event?.let { decryptionCache.relays(it) } ?: emptySet() } val flow = @@ -67,29 +68,23 @@ class TrustedRelayListState( .flowOn(Dispatchers.Default) .stateIn( scope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, emptySet(), ) - fun saveRelayList( - trustedRelays: List, - onDone: (TrustedRelayListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent { val relayListForTrusted = getTrustedRelayList() - if (relayListForTrusted != null && relayListForTrusted.tags.isNotEmpty()) { - TrustedRelayListEvent.updateRelayList( + return if (relayListForTrusted != null && relayListForTrusted.tags.isNotEmpty()) { + TrustedRelayListEvent.Companion.updateRelayList( earlierVersion = relayListForTrusted, relays = trustedRelays, signer = signer, - onReady = onDone, ) } else { - TrustedRelayListEvent.createFromScratch( + TrustedRelayListEvent.Companion.create( relays = trustedRelays, signer = signer, - onReady = onDone, ) } } @@ -98,7 +93,7 @@ class TrustedRelayListState( settings.backupTrustedRelayList?.let { Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}") @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } } scope.launch(Dispatchers.Default) { 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 index 09bcc225f..cb534fa39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt @@ -28,23 +28,20 @@ import com.vitorpamplona.quartz.nip56Reports.ReportType class ReportAction { companion object { - fun report( + suspend fun report( user: User, type: ReportType, by: User, signer: NostrSigner, - onDone: (ReportEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - + ): ReportEvent? { if (user.hasReport(by, type)) { // has already reported this note - return + return null } val template = ReportEvent.build(user.pubkeyHex, type) - signer.sign(template, onDone) + return signer.sign(template) } suspend fun report( @@ -53,17 +50,14 @@ class ReportAction { content: String = "", by: User, signer: NostrSigner, - onDone: (ReportEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - + ): ReportEvent? { if (note.hasReport(by, type)) { // has already reported this note - return + return null } - note.event?.let { - signer.sign(ReportEvent.build(it, type), onDone) + 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 index 1018b03ae..9df03f441 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -105,26 +105,19 @@ class Nip65RelayListState( emptySet(), ) - fun saveRelayList( - relays: List, - onDone: (AdvertisedRelayListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - + suspend fun saveRelayList(relays: List): AdvertisedRelayListEvent { val nip65RelayList = getNIP65RelayList() - if (nip65RelayList != null) { + return if (nip65RelayList != null) { AdvertisedRelayListEvent.replaceRelayListWith( earlierVersion = nip65RelayList, newRelays = relays, signer = signer, - onReady = onDone, ) } else { AdvertisedRelayListEvent.createFromScratch( relays = relays, signer = signer, - onReady = onDone, ) } } 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 000000000..2ea70cb3a --- /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 index 2690fb53b..9406632f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt @@ -26,11 +26,10 @@ 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.hints.types.AddressHint 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.utils.tryAndWait +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -44,11 +43,11 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlin.coroutines.resume class CommunityListState( val signer: NostrSigner, val cache: LocalCache, + val decryptionCache: CommunityListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -60,20 +59,13 @@ class CommunityListState( fun getCommunityList(): CommunityListEvent? = getCommunityListNote().event as? CommunityListEvent - suspend fun communityListWithBackup(note: Note): Set = - communityList( - note.event as? CommunityListEvent ?: settings.backupCommunityList, - ) - - suspend fun communityList(event: CommunityListEvent?): Set = - tryAndWait { continuation -> - event?.publicAndPrivateCommunities(signer) { - continuation.resume(it) - } - } ?: emptySet() + 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> = + val flow: StateFlow> = getCommunityListFlow() .transformLatest { noteState -> emit(communityListWithBackup(noteState.note)) @@ -90,9 +82,9 @@ class CommunityListState( val flowSet: StateFlow> = flow .map { hint -> - hint.mapTo(mutableSetOf()) { it.addressId } + hint.mapTo(mutableSetOf()) { it.address.toValue() } }.onStart { - emit(flow.value.mapTo(mutableSetOf()) { it.addressId }) + emit(flow.value.mapTo(mutableSetOf()) { it.address.toValue() }) }.flowOn(Dispatchers.Default) .stateIn( scope, @@ -100,54 +92,48 @@ class CommunityListState( emptySet(), ) - fun follow( - communities: List, - onDone: (CommunityListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(communities: List): CommunityListEvent { val communityList = getCommunityList() - val partialHint = communities.mapNotNull { it.toEventHint() } - if (communityList == null) { - CommunityListEvent.createCommunities(partialHint, true, signer, onReady = onDone) + 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.addCommunities(communityList, partialHint, true, signer, onReady = onDone) + CommunityListEvent.add(communityList, communityTags, true, signer) } } - fun follow( - community: AddressableNote, - onDone: (CommunityListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun follow(community: AddressableNote): CommunityListEvent? { val communityList = getCommunityList() + if (community.address.kind != CommunityDefinitionEvent.KIND) return communityList - val fullHint = community.toEventHint() - if (fullHint != null) { - if (communityList == null) { - CommunityListEvent.createCommunity(fullHint, true, signer, onReady = onDone) - } else { - CommunityListEvent.addCommunity(communityList, fullHint, true, signer, onReady = onDone) - } + return if (communityList == null) { + CommunityListEvent.create( + CommunityTag(community.address, community.relayHintUrl()), + true, + signer, + ) } else { - val partialHint = community.toATag() - if (communityList == null) { - CommunityListEvent.createCommunity(partialHint, true, signer, onReady = onDone) - } else { - CommunityListEvent.addCommunity(communityList, partialHint, true, signer, onReady = onDone) - } + CommunityListEvent.add(communityList, CommunityTag(community.address, community.relayHintUrl()), true, signer) } } - fun unfollow( - community: AddressableNote, - onDone: (CommunityListEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun unfollow(community: AddressableNote): CommunityListEvent? { val communityList = getCommunityList() + if (community.address.kind != CommunityDefinitionEvent.KIND) return communityList - if (communityList != null) { - CommunityListEvent.removeCommunity(communityList, community.address.toValue(), signer, onReady = onDone) + return if (communityList != null) { + CommunityListEvent.remove(communityList, CommunityTag(community.address, community.relayHintUrl()), signer) + } else { + null } } @@ -156,9 +142,7 @@ class CommunityListState( Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } + LocalCache.justConsumeMyOwnEvent(event) } } 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 index e76ee0522..8a65a3de7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -53,17 +53,14 @@ class AppSpecificState( fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow - fun saveNewAppSpecificData(onDone: (AppSpecificDataEvent) -> Unit) { + suspend fun saveNewAppSpecificData(): AppSpecificDataEvent { val toInternal = settings.syncedSettings.toInternal() - signer.nip44Encrypt(JsonMapper.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted -> - AppSpecificDataEvent.create( - dTag = APP_SPECIFIC_DATA_D_TAG, - description = encrypted, - otherTags = emptyArray(), - signer = signer, - onReady = onDone, - ) - } + return AppSpecificDataEvent.create( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = signer.nip44Encrypt(JsonMapper.mapper.writeValueAsString(toInternal), signer.pubKey), + otherTags = emptyArray(), + signer = signer, + ) } init { @@ -72,16 +69,13 @@ class AppSpecificState( @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(event) - signer.decrypt(event.content, event.pubKey) { decrypted -> - try { - 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 $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } + 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) } } } @@ -91,18 +85,13 @@ class AppSpecificState( getAppSpecificDataFlow().collect { Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}") (it.note.event as? AppSpecificDataEvent)?.let { - signer.decrypt(it.content, it.pubKey) { decrypted -> - val syncedSettings = - try { - JsonMapper.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() - } - + 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 index 943dad95f..05af74039 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt @@ -66,11 +66,7 @@ class FileStorageServerListState( emptyList(), ) - fun saveFileServersList( - servers: List, - onDone: (FileServersEvent) -> Unit, - ) { - if (!signer.isWriteable()) return + suspend fun saveFileServersList(servers: List): FileServersEvent { val serverList = getFileServersList() val template = @@ -80,6 +76,6 @@ class FileStorageServerListState( FileServersEvent.build(servers) } - signer.sign(template, onDone) + 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 index 927a730c7..5d8557d35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -29,7 +29,6 @@ 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 com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -38,7 +37,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlin.coroutines.resume class BlossomServerListState( val signer: NostrSigner, @@ -70,26 +68,19 @@ class BlossomServerListState( emptyList(), ) - fun saveBlossomServersList( - servers: List, - onDone: (BlossomServersEvent) -> Unit, - ) { - if (!signer.isWriteable()) return - + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { val serverList = getBlossomServersList() - if (serverList != null && serverList.tags.isNotEmpty()) { + return if (serverList != null && serverList.tags.isNotEmpty()) { BlossomServersEvent.updateRelayList( earlierVersion = serverList, relays = servers, signer = signer, - onReady = onDone, ) } else { BlossomServersEvent.createFromScratch( relays = servers, signer = signer, - onReady = onDone, ) } } @@ -98,26 +89,10 @@ class BlossomServerListState( hash: HexKey, size: Long, alt: String, - ): BlossomAuthorizationEvent? { - if (!signer.isWriteable()) return null - - return tryAndWait { continuation -> - BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) { - continuation.resume(it) - } - } - } + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ): BlossomAuthorizationEvent? { - if (!signer.isWriteable()) return null - - return tryAndWait { continuation -> - BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) { - continuation.resume(it) - } - } - } + ): BlossomAuthorizationEvent? = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) } 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 79% 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 e0e03a1b3..07e0b77bf 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,22 +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.amethyst.model +package com.vitorpamplona.amethyst.model.privateChats import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.checkNotInMainThread 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 activeSenders: Set = setOf() var roomMessages: Set = setOf() - var subject: String? = null + var subject = MutableStateFlow(null) var subjectCreatedAt: Long? = null + var ownerSentMessage: Boolean = false + var lastMessage: Note? = null @Synchronized fun addMessageSync(msg: Note) { @@ -43,15 +48,20 @@ class Chatroom { roomMessages = roomMessages + msg 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() } } @@ -69,13 +79,13 @@ class Chatroom { .sortedBy { it.createdAt() } .lastOrNull() ?.let { - subject = it.event?.subject() + subject.tryEmit(it.event?.subject()) subjectCreatedAt = it.createdAt() } } } - 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) 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 000000000..797070e57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.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.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 chatrooms = LargeCache() + private set + + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = chatrooms.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.roomMessages) { + privateChatroom.addMessageSync(msg) + } + } + + fun addMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg !in privateChatroom.roomMessages) { + privateChatroom.addMessageSync(msg) + if (msg.author?.pubkeyHex == ownerPubKey) { + privateChatroom.ownerSentMessage = true + } + } + } + + fun createChatroom(withKey: ChatroomKey) { + getOrCreatePrivateChatroom(withKey) + } + + fun removeMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg in privateChatroom.roomMessages) { + privateChatroom.removeMessageSync(msg) + } + } + + fun removeMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg in privateChatroom.roomMessages) { + privateChatroom.removeMessageSync(msg) + } + } + + fun hasSentMessagesTo(key: ChatroomKey?): Boolean { + if (key == null) return false + return chatrooms.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 index ccee0916e..9d0fed219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.model.serverList import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState -import com.vitorpamplona.amethyst.model.nip51Lists.GeohashListState -import com.vitorpamplona.amethyst.model.nip51Lists.HashtagListState +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.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlin.collections.map class MergedFollowListsState( val kind3List: FollowListState, @@ -45,14 +46,14 @@ class MergedFollowListsState( kind3: FollowListState.Kind3Follows, hashtages: Set, geohashes: Set, - community: Set, + community: Set, ): FollowListState.Kind3Follows = FollowListState.Kind3Follows( kind3.authors, kind3.authorsPlusMe, kind3.hashtags + hashtages, kind3.geotags + geohashes, - kind3.communities + community.map { it.addressId }, + kind3.communities + community.map { it.address.toValue() }, ) val flow: StateFlow = 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 index ac719c9d1..b016b1eae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt @@ -23,7 +23,7 @@ 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.FollowListOutboxRelays -import com.vitorpamplona.amethyst.model.nip51Lists.TrustedRelayListState +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 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 index e0bf5faa0..77e279742 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt @@ -23,8 +23,8 @@ 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.nip50Search.SearchRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.TrustedRelayListState +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 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 000000000..2ab279765 --- /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 index dbaf694a0..50af84a2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -51,6 +51,7 @@ class FeedTopNavFilterState( val locationFlow: StateFlow, val followsRelays: StateFlow>, val blockedRelays: StateFlow>, + val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, ) { @@ -62,7 +63,7 @@ class FeedTopNavFilterState( else -> { val note = LocalCache.checkGetOrCreateAddressableNote(listName) if (note != null) { - NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays) + NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays, caches) } else { UnknownFeedFlow(listName) } 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 index 36b9672e4..e2dc5baa9 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -32,11 +33,11 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors 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.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +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 @@ -49,30 +50,31 @@ class NoteFeedFlow( val signer: NostrSigner, val allFollowRelays: StateFlow>, val blockedRelays: StateFlow>, + val caches: FeedDecryptionCaches, ) : IFeedFlowsType { fun process(noteEvent: Event): IFeedTopNavFilter = when (noteEvent) { is PeopleListEvent -> { if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { - MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays) + MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) } else { - AuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays) + AuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) } } is MuteListEvent -> { - MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedUsersAndWords().users, blockedRelays) + MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), blockedRelays) } is FollowListEvent -> { - AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet(), blockedRelays) + AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays) } is CommunityListEvent -> { - AllCommunitiesTopNavFilter(noteEvent.publicAndCachedPrivateCommunityIds().toSet(), blockedRelays) + AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) } is HashtagListEvent -> { - HashtagTopNavFilter(noteEvent.publicAndCachedPrivateHashtags(), allFollowRelays) + HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), allFollowRelays) } is GeohashListEvent -> { - LocationTopNavFilter(noteEvent.publicAndCachedPrivateGeohash(), allFollowRelays) + LocationTopNavFilter(caches.geohashCache.cachedGeohashes(noteEvent), allFollowRelays) } is CommunityDefinitionEvent -> { SingleCommunityTopNavFilter( @@ -89,50 +91,25 @@ class NoteFeedFlow( when (noteEvent) { is PeopleListEvent -> { if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { - emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays)) - - noteEvent.publicAndPrivateUsersAndWords(signer)?.let { - emit(MutedAuthorsByOutboxTopNavFilter(it.users, blockedRelays)) - } + emit(MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) } else { - emit(AuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays)) - - noteEvent.publicAndPrivateUsersAndWords(signer)?.let { - emit(AuthorsByOutboxTopNavFilter(it.users, blockedRelays)) - } + emit(AuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) } } is MuteListEvent -> { - emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedUsersAndWords().users, blockedRelays)) - - noteEvent.publicAndPrivateUsersAndWords(signer)?.let { - emit(MutedAuthorsByOutboxTopNavFilter(it.users, blockedRelays)) - } + emit(MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), blockedRelays)) } is FollowListEvent -> { - emit(AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet(), blockedRelays)) + emit(AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays)) } is CommunityListEvent -> { - emit(AllCommunitiesTopNavFilter(noteEvent.publicCommunityIds().toSet(), blockedRelays)) - - noteEvent.publicAndPrivateCommunities(signer)?.let { - val communities = it.map { it.addressId }.toSet() - emit(AllCommunitiesTopNavFilter(communities, blockedRelays)) - } + emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) } is HashtagListEvent -> { - emit(HashtagTopNavFilter(noteEvent.publicHashtags().toSet(), allFollowRelays)) - - noteEvent.publicAndPrivateHashtag(signer)?.let { - emit(HashtagTopNavFilter(it, allFollowRelays)) - } + emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), allFollowRelays)) } is GeohashListEvent -> { - emit(LocationTopNavFilter(noteEvent.publicGeohashes().toSet(), allFollowRelays)) - - noteEvent.publicAndPrivateGeohash(signer)?.let { - emit(LocationTopNavFilter(it, allFollowRelays)) - } + emit(LocationTopNavFilter(caches.geohashCache.geohashes(noteEvent), allFollowRelays)) } is CommunityDefinitionEvent -> { emit( 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 c33486135..42adc194f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -38,16 +38,13 @@ 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.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.utils.collectSuccessfulOperationsReturning import com.vitorpamplona.quartz.utils.mapNotNullAsync -import com.vitorpamplona.quartz.utils.tryAndWait 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 -import kotlin.coroutines.resume import kotlin.math.round class ZapPaymentHandler( @@ -194,7 +191,7 @@ class ZapPaymentHandler( onProgress(0.75f) } - if (account.hasWalletConnectSetup()) { + if (account.nip47SignerState.hasWalletConnectSetup()) { payViaNWC(payables, note, onError = onError, onProgress = { onProgress(it * 0.25f + 0.75f) // keeps within range. }, context) @@ -234,7 +231,14 @@ class ZapPaymentHandler( // makes sure the zap split user receives the zap event val userRelayList = next.user?.inboxRelays()?.toSet() ?: emptySet() - val zapRequest = prepareZapRequestIfNeeded(note, pollOption, message, zapType, next.user, userRelayList + authorRelayList) + 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 + } ZapRequestReady(next, zapRequest) } @@ -302,17 +306,12 @@ class ZapPaymentHandler( ): List { var progressAllPayments = 0.00f - return collectSuccessfulOperationsReturning( + 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 @@ -333,6 +332,11 @@ class ZapPaymentHandler( } }, ) + + progressAllPayments += 0.5f / payables.size + onProgress(progressAllPayments) + + Paid(payable, true) }, ) } @@ -372,22 +376,4 @@ class ZapPaymentHandler( invoice = invoice, ) } - - private suspend fun prepareZapRequestIfNeeded( - note: Note, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null, - additionalRelays: Set? = null, - ): LnZapRequestEvent? = - if (zapType != LnZapEvent.ZapType.NONZAP) { - tryAndWait { continuation -> - account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> - continuation.resume(zapRequest) - } - } - } else { - null - } } 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 index 9650f2358..22aba4a81 100644 --- 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 @@ -30,7 +30,7 @@ import java.util.Base64 import kotlin.coroutines.cancellation.CancellationException class V3Parser { - companion object Companion { + companion object { fun parseCashuA(cashuToken: String): GenericLoadable> { try { val base64token = cashuToken.replace("cashuA", "") 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 index ddd22fb5e..d26f54060 100644 --- 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 @@ -65,7 +65,6 @@ class V4Parser { 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") } 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 c3bfc9bfd..a9ae46535 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 dc6ed4fd8..27e0c3120 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 cfd928f67..75eeeff21 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 @@ -42,7 +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.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent @@ -86,12 +85,11 @@ class EventNotificationConsumer( ) { 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, @@ -100,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) } } } @@ -146,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 } } } @@ -191,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() && @@ -198,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) @@ -206,8 +203,7 @@ class EventNotificationConsumer( val isKnownRoom = ( - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) + chatroomList.chatrooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) ) if (isKnownRoom) { @@ -236,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() && @@ -243,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.chatrooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) if (isKnownRoom) { val content = chatNote.event?.content ?: "" @@ -274,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 @@ -290,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.chatrooms.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() @@ -308,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") @@ -358,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") @@ -373,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 b22599810..43c2279b5 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 @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.mapNotNullAsync -import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -38,7 +37,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.coroutines.executeAsync -import kotlin.coroutines.resume class RegisterAccounts( private val accounts: List, @@ -61,12 +59,8 @@ class RegisterAccounts( } return mapNotNullAsync(remainingTos) { info -> - tryAndWait { continuation -> - val signer = info.accountSettings.createSigner(Amethyst.instance.contentResolver) - RelayAuthEvent.create(info.relays, notificationToken, signer) { result -> - continuation.resume(result) - } - } + val signer = info.accountSettings.createSigner(Amethyst.instance.contentResolver) + RelayAuthEvent.create(info.relays, notificationToken, signer) } } 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 6954c3941..b88057692 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account 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/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 13e0c59e5..774bb3bde 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -29,10 +28,11 @@ 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.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +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 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 7e65c20e4..34dff46d7 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 @@ -28,8 +28,8 @@ 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.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +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 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 fbb5c5b9b..c0473e031 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 @@ -25,7 +25,7 @@ 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 = 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 8f17ce115..d1e8a3ea4 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 @@ -27,9 +27,9 @@ 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 = 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 23ef0ae47..271979361 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 @@ -29,14 +29,15 @@ 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.NoteState import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +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 @@ -277,9 +278,9 @@ fun observeUserTagFollowCount( .metadata.stateFlow .sample(1000) .mapLatest { noteState -> - (noteState.note.event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.size ?: 0 + (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0 }.onStart { - emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.size ?: 0) + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0) }.distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -305,9 +306,9 @@ fun observeUserTagFollows( .metadata.stateFlow .sample(200) .mapLatest { noteState -> - (noteState.note.event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.sorted() ?: emptyList() + (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList() }.onStart { - emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.sorted() ?: emptyList()) + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList()) }.distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -319,15 +320,21 @@ fun observeUserTagFollows( 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) @@ -342,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 + .metadata.stateFlow .sample(200) - .mapLatest { userState -> - userState.user.latestBookmarkList?.countBookmarks() ?: 0 + .mapLatest { noteState -> + (noteState.note.event as? BookmarkListEvent)?.countBookmarks() ?: 0 }.distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -661,33 +669,6 @@ 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(), 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 8392cdf54..21b3ce2ff 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 @@ -36,10 +36,10 @@ 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 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 000000000..6be8af457 --- /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/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt similarity index 52% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt index a4faa10c5..4fa495f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt @@ -18,21 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient +package com.vitorpamplona.amethyst.service.relayClient.speedLogger -import android.util.Log -import com.vitorpamplona.amethyst.service.relayClient.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 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 -import kotlin.concurrent.timer @OptIn(ExperimentalAtomicApi::class) class KindGroup( @@ -81,92 +74,3 @@ class KindGroup( 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: 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") - } - } - } - - // 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 : 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/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt new file mode 100644 index 000000000..8cbc9a1cb --- /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/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 523545b7c..424a3cd82 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 232ba281a..c6859a460 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 @@ -166,6 +167,8 @@ class UploadOrchestrator { originalHash = originalHash, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(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) @@ -207,6 +210,8 @@ class UploadOrchestrator { 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/nip96/ServerInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index e473a9424..55cf8147f 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 @@ -53,7 +53,7 @@ class ServerInfoRetriever { 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}", ) } 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 ca69bdf2a..269e4ed12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -163,14 +163,14 @@ fun uriToRoute( is NEvent -> { routeFor( note = LocalCache.getOrCreateNote(nip19.hex), - loggedIn = account.userProfile(), + loggedIn = account, ) ?: Route.EventRedirect(nip19.hex) } is NAddress -> { routeFor( note = LocalCache.getOrCreateAddressableNote(nip19.address()), - loggedIn = account.userProfile(), + loggedIn = account, ) ?: Route.EventRedirect(nip19.aTag()) } @@ -179,12 +179,12 @@ fun uriToRoute( if (noteEvent is AddressableEvent) { routeFor( note = LocalCache.getOrCreateAddressableNote(noteEvent.address()), - loggedIn = account.userProfile(), + loggedIn = account, ) ?: Route.EventRedirect(noteEvent.addressTag()) } else { routeFor( note = LocalCache.getOrCreateNote(nip19.event.id), - loggedIn = account.userProfile(), + loggedIn = account, ) ?: Route.EventRedirect(nip19.event.id) } } 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 5d10cc634..59f877ede 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 @@ -48,6 +48,8 @@ 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.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 @@ -168,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 @@ -177,7 +196,6 @@ open class EditPostViewModel : ViewModel() { val results = myMultiOrchestrator.upload( - viewModelScope, alt, if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQuality), @@ -189,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 5099ff364..544cab217 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 @@ -171,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 4e6e8a1cb..630f52573 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,13 +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.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() { @@ -83,6 +81,19 @@ open class NewMediaModel : ViewModel() { context: Context, 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 @@ -94,7 +105,6 @@ open class NewMediaModel : ViewModel() { val results = myMultiOrchestrator.upload( - viewModelScope, caption, if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQualitySlider), @@ -135,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) - continuation.resume(true) - } - } - } + val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) + myAccount.consumeAndSendNip95(nip95.first, nip95.second) } } @@ -150,20 +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( - url = it.url, - magnetUri = it.magnet, - headerInfo = it.fileHeader, - alt = caption, - contentWarningReason = if (sensitiveContent) "" else null, - originalHash = it.uploadedHash, - ) { - continuation.resume(true) - } - } - } + account?.sendHeader( + url = it.url, + magnetUri = it.magnet, + headerInfo = it.fileHeader, + alt = caption, + contentWarningReason = if (sensitiveContent) "" else null, + originalHash = it.uploadedHash, + ) } } @@ -171,17 +169,11 @@ open class NewMediaModel : ViewModel() { if (imageUrls.isNotEmpty()) { listOf( viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.sendAllAsOnePictureEvent( - urlHeaderInfo = imageUrls, - caption = caption, - contentWarningReason = if (sensitiveContent) "" else null, - ) { - 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/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 62f3ebfb6..c24c121cd 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 @@ -36,6 +36,7 @@ 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.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 @@ -212,6 +213,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/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index cdf010fb3..a3388f6ce 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 @@ -183,7 +183,7 @@ private fun DisplayNoteLink( val noteState by observeNote(it, accountViewModel) val noteIdDisplayNote = remember(noteState) { "@${noteState.note.idDisplayNote()}" } - val route = routeFor(it, accountViewModel.userProfile()) ?: Route.EventRedirect(hex) + val route = routeFor(it, accountViewModel.account) ?: Route.EventRedirect(hex) CreateClickableText( clickablePart = noteIdDisplayNote, 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 6a0cc02f3..46b0e71b9 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 @@ -767,7 +767,7 @@ private fun DisplayNoteFromTag( } else { ClickableTextPrimary( text = "@${baseNote.idNote().toShortDisplay()}", - onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, + onClick = { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }, ) } 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 0448f0275..8ab4de50c 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 @@ -28,8 +28,8 @@ 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.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.utils.TimeUtils class FilterByListParams( 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 729af27ec..bfb98950e 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 @@ -142,7 +142,7 @@ fun AppNavigation( composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs { PublicChatChannelScreen(it.id, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 81d2d0ee0..dc1260c58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.navigation.routes +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache @@ -50,7 +51,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun routeFor( note: Note, - loggedIn: User, + loggedIn: Account, ): Route? { val noteEvent = note.event ?: return Route.EventRedirect(note.idHex) @@ -59,10 +60,10 @@ fun routeFor( 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 { @@ -77,9 +78,9 @@ fun routeFor( 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.createChatroom(room) + return Route.Room(room) } else if (innerEvent is AddressableEvent) { return Route.Note(noteEvent.aTag().toTag()) } else { @@ -102,9 +103,9 @@ fun routeFor( 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.createChatroom(room) + return Route.Room(room) } else if (noteEvent is CommunityDefinitionEvent) { return Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) } else if (noteEvent is GiftWrapEvent) { @@ -159,18 +160,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.createChatroom(room) - return Route.Room(room.hashCode(), draftMessage, replyId, draftId) + return Route.Room(room, draftMessage, replyId, draftId) } fun routeToMessage( @@ -195,26 +196,26 @@ 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 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/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index bd9d01613..c62974fb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -24,6 +24,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.serialization.Serializable sealed class Route { @@ -122,11 +123,20 @@ 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 RoomByAuthor( val id: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 961448f84..c031d040a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index b2f984ffa..717a4439c 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 @@ -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/MessageSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 7ad06f15b..70abe8bec 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 @@ -85,7 +85,7 @@ fun MessageSetCompose( scope.launch { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } } }, 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 04b114d9e..6caa3dff4 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 @@ -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 3b91d3869..e55fbd880 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 @@ -327,7 +327,7 @@ fun DisplayStatusInner( onClick = { routeFor( note, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { @@ -349,7 +349,7 @@ fun DisplayStatusInner( onClick = { routeFor( it, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { 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 61ffa5e0c..73f178b67 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 @@ -183,12 +183,12 @@ 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.BlockedRelayListEvent -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.TrustedRelayListEvent +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.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 @@ -474,7 +474,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) 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 dace09a02..84339a6af 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 @@ -94,7 +94,7 @@ 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 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 35a4ae7da..c448e0179 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 @@ -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 525492b30..369252068 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/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 56ad6b551..303b2ea96 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 @@ -351,10 +351,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 } } } @@ -580,7 +579,7 @@ private fun ReplyReactionWithDialog( nav: INav, ) { ReplyReaction(baseNote, grayTint, accountViewModel) { - nav.nav { routeReplyTo(baseNote, accountViewModel.userProfile()) } + nav.nav { routeReplyTo(baseNote, accountViewModel.account) } } } @@ -1196,10 +1195,9 @@ fun ObserveZapAmountText( 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 } } } 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 38d0e4503..16060c726 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 @@ -52,6 +52,7 @@ 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.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -70,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 @@ -97,17 +97,21 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve 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 } @@ -134,8 +138,7 @@ class UpdateReactionTypeViewModel : ViewModel() { } fun sendPost() { - viewModelScope.launch(Dispatchers.IO) { - account?.changeReactionTypes(reactionSet) + accountViewModel.changeReactionTypes(reactionSet) { nextChoice = TextFieldValue("") } } @@ -144,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 @@ -161,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) } @@ -414,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 34aa68b64..907f5bf9a 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 @@ -100,137 +95,10 @@ 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.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.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 - ?.url - ?.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 { 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 - } - - 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.url) - walletConnectSecret = TextFieldValue(contact.secret ?: "") - } -} - @Composable fun UpdateZapAmountDialog( onClose: () -> Unit, @@ -238,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) } 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 000000000..7195785f3 --- /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/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 4fe375a24..a75771bb5 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 @@ -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() }, ) @@ -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.isInPrivateBookmarks(note), + isPublicBookmarkNote = accountViewModel.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 aef75ef97..b8fa57650 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 @@ -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 5da9dc90e..1826aa018 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 @@ -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 27218654c..3e763cb79 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 @@ -98,13 +99,11 @@ 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.tryAndWait 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.coroutines.resume @Stable open class CommentPostViewModel : @@ -207,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) } } } @@ -319,11 +316,7 @@ open class CommentPostViewModel : } } - tryAndWait { continuation -> - accountViewModel?.account?.signAndComputeBroadcast(template, extraNotesToBroadcast) { - continuation.resume(it) - } - } + accountViewModel?.account?.signAndComputeBroadcast(template, extraNotesToBroadcast) accountViewModel?.deleteDraft(draftTag.current) @@ -372,7 +365,7 @@ open class CommentPostViewModel : if (replyingTo != null) { val eventHint = replyingTo.toEventHint() ?: return null - CommentEvent.Companion.replyBuilder( + CommentEvent.replyBuilder( msg = tagger.message, replyingTo = eventHint, ) { @@ -392,7 +385,7 @@ open class CommentPostViewModel : } } else { val externalIdentity = externalIdentity ?: return null - CommentEvent.Companion.replyExternalIdentity( + CommentEvent.replyExternalIdentity( msg = tagger.message, extId = externalIdentity, ) { @@ -420,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, @@ -437,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 @@ -446,7 +455,6 @@ open class CommentPostViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, alt, contentWarningReason, MediaCompressor.Companion.intToCompressorQuality(mediaQuality), @@ -458,14 +466,13 @@ open class CommentPostViewModel : if (results.allGood) { results.successful.forEach { state -> if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + 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 (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = 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 d373f98eb..3c75950cd 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 @@ -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 4e65ca63d..0b2094448 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 @@ -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/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 996fa443b..60346dd4a 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,6 +54,7 @@ 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.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 @@ -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( @@ -373,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/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index 0f0ebbd65..ec18a8c73 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 @@ -61,7 +61,7 @@ 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 @@ -108,7 +108,7 @@ fun DisplayFollowList( } 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/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 8a1936bbe..d25ae7476 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 @@ -271,7 +271,7 @@ 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, note.toNostrUri(), null, accountViewModel, nav) 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 23503060d..45d867a2d 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 @@ -55,7 +55,7 @@ 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/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index be3c35328..a17db9799 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 @@ -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) } } 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 efa085ddd..d8382e305 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 @@ -43,8 +43,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.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.navs.INav @@ -58,13 +60,8 @@ 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.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent -import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent +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 @@ -79,7 +76,9 @@ fun DisplayRelaySet( val relays by remember(noteEvent) { mutableStateOf( - noteEvent.relays().toImmutableList(), + RelayListCard( + noteEvent.relays().toImmutableList(), + ), ) } @@ -110,14 +109,18 @@ fun DisplayNIP65RelayList( val writeRelays by remember(baseNote) { mutableStateOf( - noteEvent.writeRelaysNorm()?.toImmutableList() ?: persistentListOf(), + RelayListCard( + noteEvent.writeRelaysNorm() ?: emptyList(), + ), ) } val readRelays by remember(baseNote) { mutableStateOf( - noteEvent.readRelaysNorm()?.toImmutableList() ?: persistentListOf(), + RelayListCard( + noteEvent.readRelaysNorm() ?: emptyList(), + ), ) } @@ -152,7 +155,9 @@ fun DisplayDMRelayList( val relays by remember(baseNote) { mutableStateOf( - noteEvent.relays().toImmutableList(), + RelayListCard( + noteEvent.relays(), + ), ) } @@ -173,14 +178,9 @@ fun DisplaySearchRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? SearchRelayListEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().toImmutableList(), - ) - } + val relays by accountViewModel.account.searchRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.searchRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) DisplayRelaySet( relays, @@ -199,14 +199,9 @@ fun DisplayBlockedRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? BlockedRelayListEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().toImmutableList(), - ) - } + val relays by accountViewModel.account.blockedRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.blockedRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) DisplayRelaySet( relays, @@ -225,14 +220,9 @@ fun DisplayTrustedRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? TrustedRelayListEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().toImmutableList(), - ) - } + val relays by accountViewModel.account.trustedRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.trustedRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) DisplayRelaySet( relays, @@ -246,7 +236,7 @@ fun DisplayTrustedRelayList( @Composable fun DisplayRelaySet( - relays: ImmutableList, + relay: RelayListCard, relayListName: String, relayDescription: String?, backgroundColor: MutableState, @@ -257,9 +247,9 @@ fun DisplayRelaySet( val toMembersShow = if (expanded) { - relays + relay.relays } else { - relays.take(3) + relay.relays.take(3) } Text( @@ -313,7 +303,7 @@ fun DisplayRelaySet( } } - if (relays.size > 3 && !expanded) { + if (relay.relays.size > 3 && !expanded) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, 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 7a75c42ff..fa80c2a34 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 @@ -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/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index d7d1fff85..d9d91d3fb 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 @@ -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/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 6cb102b28..12b91d068 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 @@ -301,7 +301,7 @@ class AccountStateViewModel : ViewModel() { backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), - backupChannelList = ChannelListEvent.create(DefaultChannels, tempSigner), + backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), torSettings = TorSettingsFlow.build(torSettings), ) 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 b55718b84..a563316d3 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 @@ -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 @@ -297,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() } 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 024aab78d..883571d99 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 @@ -54,7 +54,6 @@ 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.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier @@ -94,10 +93,10 @@ 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.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 @@ -113,9 +112,10 @@ 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.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +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 @@ -128,7 +128,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.mapNotNullAsync -import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -151,7 +150,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient -import kotlin.coroutines.resume +import java.util.Locale @Stable class AccountViewModel( @@ -162,15 +161,15 @@ class AccountViewModel( Dao { val account = Account( - accountSettings, - accountSettings.createSigner(app.contentResolver), - app.locationManager.geohashStateFlow, - LocalCache, - app.client, - viewModelScope, + 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 @@ -418,7 +417,7 @@ class AccountViewModel( fun hasBoosted(baseNote: Note): Boolean = account.hasBoosted(baseNote) fun deleteBoostsTo(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(account.boostsTo(note)) } + runIOCatching { account.delete(account.boostsTo(note)) } } suspend fun calculateIfNoteWasZappedByAccount( @@ -430,42 +429,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() @@ -481,7 +474,8 @@ class AccountViewModel( } else { showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) + + ZapraiserStatus(newZapraiserProgress, newZapraiserLeft) } } @@ -532,7 +526,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, @@ -566,7 +560,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, @@ -681,25 +675,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( @@ -707,29 +696,29 @@ 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(emojiPack: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(emojiPack) } + runIOCatching { account.removeEmojiPack(emojiPack) } } fun addEmojiPack(emojiPack: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(emojiPack) } + runIOCatching { account.addEmojiPack(emojiPack) } } fun addMediaToGallery( @@ -741,46 +730,43 @@ class AccountViewModel( 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) } + runIOCatching { account.removeFromGallery(note) } } fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex)) + fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + fun addPrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } + runIOCatching { account.addBookmark(note, true) } } fun addPublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, false) } + runIOCatching { account.addBookmark(note, false) } } fun removePrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, true) } + runIOCatching { account.removeBookmark(note, true) } } fun removePublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, false) } + runIOCatching { account.removeBookmark(note, false) } } - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - account.isInPrivateBookmarks(note, onReady) - } + fun isInPrivateBookmarks(note: Note): Boolean = account.isInPrivateBookmarks(note) fun isInPublicBookmarks(note: Note): Boolean = account.isInPublicBookmarks(note) fun broadcast(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) } + runIOCatching { account.broadcast(note) } } fun timestamp(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) } + runIOCatching { account.timestamp(note) } } var lastTimeItTriedToUpdateAttestations: Long = 0 @@ -790,22 +776,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) @@ -815,56 +795,82 @@ class AccountViewModel( fun decrypt( note: Note, onReady: (String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } + ) = runIOCatching { + account.decryptContent(note)?.let { onReady(it) } + } + + 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, + ) + } + } + } + + 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 @@ -878,20 +884,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 @@ -908,65 +906,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)) } } @@ -1196,24 +1191,6 @@ 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(), - ) - } - } - } - fun checkVideoIsOnline( videoUrl: String, onDone: (Boolean) -> Unit, @@ -1252,7 +1229,7 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { for (note in notes) { note.event?.createdAt?.let { date -> - val route = routeFor(note, accountViewModel.account.userProfile()) + val route = routeFor(note, accountViewModel.account) route?.let { if (route is Route.Room) { account.markAsRead("Room/${route.id}", date) @@ -1269,12 +1246,12 @@ class AccountViewModel( fun createChatRoomFor( user: User, - then: (Int) -> Unit, + then: (ChatroomKey) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) - account.userProfile().createChatroom(withKey) - then(withKey.hashCode()) + account.chatroomList.createChatroom(withKey) + then(withKey) } } @@ -1292,12 +1269,13 @@ class AccountViewModel( override fun create(modelClass: Class): T = AccountViewModel(accountSettings, settings, app) as T } - private var collectorJob: Job? = null + private var collectorJobNew: Job? = null + private var collectorJobDeleted: Job? = null private val bundlerInsert = BundledInsert>(3000, Dispatchers.Default) init { Log.d("Init", "AccountViewModel") - collectorJob = + collectorJobNew = viewModelScope.launch(Dispatchers.Default) { feedStates.init() // awaits for init to finish before starting to capture new events. @@ -1311,7 +1289,24 @@ class AccountViewModel( logTime("AccountViewModel newEventBundle Update with ${newNotes.size} new notes") { feedStates.updateFeedsWith(newNotes) upgradeAttestations() - precomputeNewEvents(newNotes) + viewModelScope.launch(Dispatchers.Default) { + newNotesPreProcessor.runNew(newNotes) + } + } + } + } + + collectorJobDeleted = + 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) } } } @@ -1321,7 +1316,8 @@ class AccountViewModel( Log.d("Init", "AccountViewModel onCleared") feedStates.destroy() bundlerInsert.cancel() - collectorJob?.cancel() + collectorJobNew?.cancel() + collectorJobDeleted?.cancel() super.onCleared() } @@ -1431,92 +1427,87 @@ 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) } } } @@ -1543,12 +1534,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, @@ -1600,12 +1586,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?) @@ -1649,7 +1634,12 @@ class AccountViewModel( ) { viewModelScope.launch(Dispatchers.IO) { try { - val zapRequest = prepareZapRequestIfNeeded(user, message, defaultZapType()) + val zapRequest = + if (defaultZapType() != LnZapEvent.ZapType.NONZAP) { + account.createZapRequestFor(user, message, defaultZapType()) + } else { + null + } val invoice = LightningAddressResolver().lnAddressInvoice( @@ -1672,21 +1662,6 @@ class AccountViewModel( } } - private suspend fun prepareZapRequestIfNeeded( - user: User, - message: String, - zapType: LnZapEvent.ZapType, - ): LnZapRequestEvent? = - if (zapType != LnZapEvent.ZapType.NONZAP) { - tryAndWait { continuation -> - account.createZapRequestFor(user, message, zapType) { zapRequest -> - continuation.resume(zapRequest) - } - } - } else { - null - } - fun saveMediaToGallery( videoUri: String?, mimeType: String?, @@ -1741,16 +1716,16 @@ class AccountViewModel( 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) 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 000000000..2343b07b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -0,0 +1,382 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.nip10Notes.TextNoteEvent +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::otsResolver) + } + + is DraftEvent -> { + // Avoid decrypting over and over again if the event already exist. + if (event.pubKey == account.signer.pubKey) { + if (!event.isDeleted()) { + if (account.draftsDecryptionCache.preCachedDraft(event) == null) { + account.draftsDecryptionCache.cachedDraft(event)?.let { rumor -> + 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) { + if (!event.isDeleted()) { + if (account.draftsDecryptionCache.preCachedDraft(event) == null) { + account.draftsDecryptionCache.cachedDraft(event)?.let { + deindexDraftAsRealEvent(eventNote, it) + } + account.draftsDecryptionCache.delete(event) + } + } + } + } + + 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, + ) { + 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 { + cache.getOrCreateEphemeralChannel(it).addNote(draftEventWrap, null) + } + } + is ChannelMessageEvent -> { + rumor.channelId()?.let { channelId -> + cache.checkGetOrCreatePublicChatChannel(channelId)?.addNote(draftEventWrap, null) + } + } + is LiveActivitiesChatMessageEvent -> { + rumor.activityAddress()?.let { channelId -> + cache.getOrCreateLiveChannel(channelId).addNote(draftEventWrap, null) + } + } + is TextNoteEvent -> { + val replyTo = cache.computeReplyTo(rumor) + replyTo.forEach { it.addReply(draftEventWrap) } + } + } + } + + fun deindexDraftAsRealEvent( + draftEventWrap: Note, + rumor: Event, + ) { + when (rumor) { + is PrivateDmEvent -> { + if (rumor.canDecrypt(account.signer.pubKey)) { + 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.removeMessage(key, draftEventWrap) + } + } + is ChatMessageEncryptedFileHeaderEvent -> { + if (rumor.isIncluded(account.signer.pubKey)) { + val key = rumor.chatroomKey(account.signer.pubKey) + account.chatroomList.removeMessage(key, draftEventWrap) + } + } + is ChannelMessageEvent -> { + rumor.channelId()?.let { channelId -> + cache.getPublicChatChannelIfExists(channelId)?.removeNote(draftEventWrap) + } + } + is EphemeralChatEvent -> { + rumor.roomId()?.let { + cache.getEphemeralChatChannelIfExists(it)?.removeNote(draftEventWrap) + } + } + is LiveActivitiesChatMessageEvent -> { + rumor.activityAddress()?.let { channelId -> + cache.getLiveActivityChannelIfExists(channelId)?.removeNote(draftEventWrap) + } + } + is TextNoteEvent -> { + val replyTo = cache.computeReplyTo(rumor) + replyTo.forEach { it.removeReply(draftEventWrap) } + } + } + } + + 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 c7af1a2d5..39312e94d 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,7 @@ 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 @@ -50,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -184,13 +184,12 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { val launcher: (Intent) -> Unit = { intent -> try { launcher.launch(intent) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) + } catch (e: ActivityNotFoundException) { accountViewModel.toastManager.toast( R.string.error_opening_external_signer, R.string.error_opening_external_signer_description, ) + throw e } } 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 6b5635326..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt +++ /dev/null @@ -1,162 +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.pubKey == account.signer.pubKey) { - if (!event.isDeleted()) { - if (event.preCachedDraft(account.signer) == null) { - 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) - } - - 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) - } - } -} 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 dd013814e..ed958bead 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 @@ -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 6aa7fdd26..85cdc1d77 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 3b2925007..34a3962fb 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/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 79bb6ca19..28a4156c4 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.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/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt index 5f4fcf3a7..bf5808d44 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,7 +34,7 @@ class ChatroomFeedFilter( override fun feedKey(): String = withUser.hashCode().toString() override fun feed(): List { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList() + val messages = account.chatroomList.getOrCreatePrivateChatroom(withUser) return messages.roomMessages .filter { account.isAcceptable(it) } @@ -43,7 +43,7 @@ class ChatroomFeedFilter( } override fun applyFilter(collection: Set): Set { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptySet() + val messages = account.chatroomList.getOrCreatePrivateChatroom(withUser) return collection.filter { it in messages.roomMessages && account.isAcceptable(it) }.toSet() } 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 5b6ce89bf..ec79a82c6 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 @@ -75,7 +75,12 @@ fun NewChatroomSubjectDialog( Surface { val groupName = remember { - mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") + mutableStateOf( + accountViewModel.account.chatroomList.chatrooms + .get(room) + ?.subject + ?.value ?: "", + ) } val message = remember { mutableStateOf("") } val scope = rememberCoroutineScope() 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 87aa3fff2..e5f0a008f 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 c1f73520b..2001c2969 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) } @@ -358,13 +354,13 @@ class ChatNewMessageViewModel : 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 +383,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 +408,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 +533,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 +569,7 @@ class ChatNewMessageViewModel : fun updateRoomFromUsersInput() { viewModelScope.launch(Dispatchers.Default) { delay(300) - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel!!) + val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel) toUsersTagger.run() val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } @@ -648,10 +639,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.shouldUseTorForImageDownload(item.link.url)) + } } message = message.replaceCurrentWord(wordToInsert) @@ -701,7 +691,7 @@ class ChatNewMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), 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 6d7824919..c47094f16 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 @@ -55,7 +55,6 @@ 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 @@ -127,6 +126,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 @@ -152,10 +152,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)) } 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 8d22aeeb6..344fc1bf5 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/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index 7471ba867..db5c43d95 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/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index b923e5812..0df3d2450 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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata +import android.graphics.Picture import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -55,6 +56,7 @@ 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 @@ -67,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier 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( @@ -133,10 +136,17 @@ private fun ChannelMetadataScaffold( nav.popBack() }, onPost = { - postViewModel.createOrUpdate { - nav.nav(routeFor(it)) + 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, + ) } - nav.popBack() }, ) } else { @@ -148,8 +158,15 @@ private fun ChannelMetadataScaffold( nav.popBack() }, onPost = { - postViewModel.createOrUpdate { } - nav.popBack() + 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, + ) + } }, ) } 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 ff1c0ab19..403dc47a4 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 @@ -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 @@ -105,16 +106,15 @@ class ChannelMetadataViewModel : ViewModel() { channelRelays.value.map { it.relay }, ) - account.signAndSendPrivatelyOrBroadcast( - template, - relayList = { it.channelInfo().relays }, - onDone = { - val channel = LocalCache.getOrCreatePublicChatChannel(it.id) - // 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 @@ -141,14 +141,13 @@ class ChannelMetadataViewModel : ViewModel() { ) } - account.signAndSendPrivatelyOrBroadcast( - template, - relayList = { it.channelInfo().relays }, - onDone = { - val channel = LocalCache.getOrCreatePublicChatChannel(it.id) - onDone(channel) - }, - ) + val signedResult = + account.signAndSendPrivatelyOrBroadcast( + template, + relayList = { it.channelInfo().relays }, + ) + val channel = LocalCache.getOrCreatePublicChatChannel(signedResult.id) + onDone(channel) } } @@ -237,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/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 152b76e2a..0468e5434 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 @@ -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) } } } @@ -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) 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 7207192f7..c3c3cb986 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 @@ -275,7 +275,7 @@ private fun UserRoomCompose( 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/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 4b35b61ba..b59fbe5d8 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.chatrooms.mapNotNull { key, chatroom -> + if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) && + !account.isAllHidden(key.users) + ) { + chatroom.lastMessage + } else { + null } } val publicChannels = account .publicChatList.flow.value - .mapNotNull { LocalCache.getPublicChatChannelIfExists(it.eventId) } .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.getEphemeralChatChannelIfExists(it) } .mapNotNull { it -> - it.notes + LocalCache + .getOrCreateEphemeralChannel(it) + .notes .filter { key, it -> account.isAcceptable(it) && it.event != null } .sortedWith(DefaultFeedOrder) .firstOrNull() @@ -229,13 +229,13 @@ class ChatroomListKnownFeedFilter( .forEach { newNote -> val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) if (roomKey != null) { - val room = account.userProfile().privateChatrooms[roomKey] + val room = account.chatroomList.chatrooms.get(roomKey) if (room != null) { if ( ( newNote.author?.pubkeyHex == me.pubkeyHex || room.senderIntersects(followingKeySet) || - me.hasSentMessagesTo(roomKey) + account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) ) { 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 6c9d1fea0..a7b1e5a33 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.chatrooms.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.chatrooms.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/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 56c1e1e33..c3048c143 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 @@ -42,7 +42,7 @@ 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.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 @@ -104,9 +104,12 @@ 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, ) } 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 c9ed3da56..2f87c2079 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 @@ -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 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 30d7da9c1..dfccfe818 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 @@ -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/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index 56140e4d4..e1c5b66ab 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,8 +27,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.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, 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 039fddc9a..1fe527ce0 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 @@ -27,8 +27,8 @@ 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, @@ -103,7 +103,7 @@ open class DiscoverChatFeedFilter( override fun sort(collection: Set): List { val lastNote = collection.associateWith { note -> - LocalCache.getPublicChatChannelIfExists(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/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index d1a9c0d80..07d278ff2 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, 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 f186b54fa..dda0079c9 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 @@ -62,7 +62,7 @@ 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 @@ -85,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(), ) } 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 index 761613871..745746ba8 100644 --- 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 @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassem 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.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent fun filterFollowSetsAllCommunities( 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 index 794c18d8a..9c657837d 100644 --- 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 @@ -27,7 +27,7 @@ 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.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsAuthors( relay: NormalizedRelayUrl, 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 index cb38081b8..47c061454 100644 --- 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 @@ -25,7 +25,7 @@ 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.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent fun filterFollowSetsByCommunity( 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 578a766d2..236a58e3a 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 @@ -25,7 +25,7 @@ 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.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsByGeohash( relay: NormalizedRelayUrl, 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 8e330a862..aa7d74d43 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 @@ -26,7 +26,7 @@ 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.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsByHashtag( relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt index 17223c80c..78b8ed8d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayF 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.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsGlobal( relays: GlobalTopNavPerRelayFilterSet, 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 2f711d703..3c60054a1 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 @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCo import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter 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 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 0ee01359d..771da9c84 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 @@ -27,8 +27,8 @@ 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 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 f790a49ed..d2f3e9a7b 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 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 2f62564a0..038c2bbc9 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( 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 9bb7e67be..25f64e65f 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 @@ -79,6 +79,7 @@ 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.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -141,17 +142,28 @@ fun NewProductScreen( titleRes = R.string.new_product, isActive = postViewModel::canPost, onCancel = { - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendDraftSync() - nav.popBack() - postViewModel.cancel() + try { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() + } + } catch (e: SignerExceptions.ReadOnlyException) { + // do nothing. } }, onPost = { - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.sendPostSync() - nav.popBack() - postViewModel.cancel() + 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, + ) } }, ) 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 cce40d4b0..b1108a34c 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 @@ -194,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) } } } @@ -387,7 +385,6 @@ open class NewProductViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, alt, contentWarningReason, MediaCompressor.intToCompressorQuality(mediaQuality), 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 85ca853cd..1aefd5a1b 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 @@ -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 = { @@ -376,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, @@ -413,7 +413,7 @@ fun FeedDVM( val amountInInvoice = try { LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (e: Exception) { + } catch (_: Exception) { null } 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 98fc314c5..34d52608e 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( 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 b0a5af92e..096b5ea76 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 @@ -114,13 +115,11 @@ 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.tryAndWait 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.coroutines.resume enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -246,14 +245,12 @@ open class ShortNotePostViewModel : 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 { @@ -478,11 +475,7 @@ open class ShortNotePostViewModel : } } - tryAndWait { continuation -> - accountViewModel?.account?.signAndComputeBroadcast(template, extraNotesToBroadcast) { - continuation.resume(it) - } - } + accountViewModel?.account?.signAndComputeBroadcast(template, extraNotesToBroadcast) accountViewModel?.deleteDraft(draftTag.current) @@ -596,6 +589,22 @@ 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 @@ -606,7 +615,6 @@ open class ShortNotePostViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, alt, contentWarningReason, MediaCompressor.intToCompressorQuality(mediaQuality), @@ -618,14 +626,13 @@ open class ShortNotePostViewModel : if (results.allGood) { results.successful.forEach { state -> if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + 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 (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -656,7 +663,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")) } 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 75f5a2d51..a76b2ac97 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 @@ -79,7 +79,7 @@ class HomeLiveFilter( val revisedOldList = oldList.filter { channel -> - channel.lastNoteCreatedAt > fiveMinsAgo + (channel.lastNote?.createdAt() ?: 0) > fiveMinsAgo } val newItemsToBeAdded = applyFilter(newItems) @@ -140,7 +140,7 @@ class HomeLiveFilter( return collection.sortedWith( compareByDescending { followCounts[it] } - .thenByDescending { it.lastNoteCreatedAt } + .thenByDescending { it.lastNote?.createdAt() ?: 0 } .thenBy { it.roomId.id } .thenBy { it.roomId.relayUrl }, ) 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 76ec59bb3..c4b0a8d30 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 @@ -39,8 +39,8 @@ 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.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 @@ -99,7 +99,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 } 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 9b4584d43..dee62a42a 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/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index f3108d494..948c3fa13 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 @@ -24,10 +24,10 @@ 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.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +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 val UserProfileListKinds = 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 be54c2dad..aeb22a742 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 @@ -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/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 633842a57..2866e5766 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 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 68221abee..776bdc8c9 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 @@ -109,8 +109,8 @@ fun DisplayLNAddress( 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/MessageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt index 9c747a612..f2a477963 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 @@ -25,20 +25,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon 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.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route +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 @Composable fun MessageButton( @@ -46,15 +43,20 @@ 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)) } } + accountViewModel.createChatRoomFor(user) { chatroomKey -> + nav.nav( + routeToMessage( + room = chatroomKey, + account = accountViewModel.account, + ), + ) + } }, contentPadding = ZeroPadding, ) { 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 2e2ea2347..bb6e918c1 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 @@ -76,7 +76,7 @@ fun LoadRedirectScreen( val event = noteState.note.event if (event != null) { withContext(Dispatchers.IO) { - routeFor(event, accountViewModel.userProfile())?.let { route -> + routeFor(event, accountViewModel.account)?.let { route -> nav.popUpTo(route, 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 afe578239..ab81f80bc 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 @@ -84,16 +84,16 @@ fun AllRelayListScreen( val localViewModel: LocalRelayListViewModel = viewModel() val connectedViewModel: ConnectedRelayListViewModel = viewModel() - dmViewModel.init(accountViewModel.account) - nip65ViewModel.init(accountViewModel.account) - searchViewModel.init(accountViewModel.account) - localViewModel.init(accountViewModel.account) - privateOutboxViewModel.init(accountViewModel.account) - connectedViewModel.init(accountViewModel.account) - blockedViewModel.init(accountViewModel.account) - trustedViewModel.init(accountViewModel.account) + dmViewModel.init(accountViewModel) + nip65ViewModel.init(accountViewModel) + searchViewModel.init(accountViewModel) + localViewModel.init(accountViewModel) + privateOutboxViewModel.init(accountViewModel) + connectedViewModel.init(accountViewModel) + blockedViewModel.init(accountViewModel) + trustedViewModel.init(accountViewModel) - LaunchedEffect(accountViewModel.account) { + LaunchedEffect(accountViewModel) { dmViewModel.load() nip65ViewModel.load() searchViewModel.load() 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 index a0bb1fa64..77f93513b 100644 --- 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 @@ -24,9 +24,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.blockedRelayList.getBlockedRelayList()?.relays() + override fun getRelayList(): List? = + account.blockedRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.saveBlockedRelayList(urlList) } } 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 37151d9c7..fabec89a7 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,7 @@ 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 @@ -34,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()) @@ -41,8 +43,9 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { var hasModified = false - fun init(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account } fun load() { @@ -52,11 +55,11 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { abstract fun getRelayList(): List? - abstract fun saveRelayList(urlList: List) + abstract suspend fun saveRelayList(urlList: List) fun create() { if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { saveRelayList(_relays.value.map { it.relay }) clear() } 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 index 416b7a379..975953387 100644 --- 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 @@ -30,6 +30,6 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { .value.available .sorted() - override fun saveRelayList(urlList: List) { + 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 8c7bc3cec..3f8df123f 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 @@ -57,9 +57,9 @@ fun AddDMRelayListDialog( ) { val postViewModel: DMRelayListViewModel = viewModel() - postViewModel.init(accountViewModel.account) + postViewModel.init(accountViewModel) - LaunchedEffect(accountViewModel.account) { + LaunchedEffect(postViewModel, accountViewModel.account) { postViewModel.load() } 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 581ed8764..01b31c931 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 @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class DMRelayListViewModel : BasicRelaySetupInfoModel() { 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/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index 9cabbb3bd..496190e8e 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 @@ -28,7 +28,7 @@ class LocalRelayListViewModel : BasicRelaySetupInfoModel() { account.localRelayList.flow.value .toList() - override fun saveRelayList(urlList: List) { + 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/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index c6067fcdb..d90292c23 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 @@ -24,9 +24,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.privateStorageRelayList.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/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 2b5d12620..a90f5aebe 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,6 +27,7 @@ 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.tags.AdvertisedRelayInfo @@ -39,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()) @@ -49,8 +51,9 @@ class Nip65RelayListViewModel : ViewModel() { var hasModified = false - fun init(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account } fun load() { @@ -60,7 +63,7 @@ class Nip65RelayListViewModel : ViewModel() { fun create() { if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { val writes = _homeRelays.value.map { it.relay }.toSet() val reads = _notificationRelays.value.map { it.relay }.toSet() 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 06edc49bc..4fce997dd 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 @@ -24,9 +24,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SearchRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.searchRelayList.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/TrustedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt index 6c3e4600f..1d1f71882 100644 --- 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 @@ -24,9 +24,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class TrustedRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.trustedRelayList.getTrustedRelayList()?.relays() + override fun getRelayList(): List? = + account.trustedRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.saveTrustedRelayList(urlList) } } 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 a8ea3ea38..b82d461cd 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.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 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.createAddress(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 573534025..651223d18 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 @@ -59,9 +59,9 @@ fun AddSearchRelayListDialog( ) { val postViewModel: SearchRelayListViewModel = viewModel() - postViewModel.init(accountViewModel.account) + postViewModel.init(accountViewModel) - LaunchedEffect(accountViewModel.account) { + LaunchedEffect(postViewModel, accountViewModel.account) { postViewModel.load() } 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 116fc3e29..6507271c1 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold 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 @@ -41,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) } 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 1a3f2f70e..02b5a801c 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 @@ -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) }, ) } 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 b8603ca60..60d35bcfc 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,7 +46,6 @@ 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.navs.EmptyNav @@ -58,8 +57,6 @@ 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/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 52e91ab63..c04fd1310 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 @@ -204,12 +204,12 @@ 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.BlockedRelayListEvent -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.TrustedRelayListEvent +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.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 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 9609d9a98..0d6e5aafe 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 @@ -421,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/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index 3e669f0dc..c95b286ab 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,8 +31,8 @@ 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.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta 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 index 309065ead..0cc8ba427 100644 --- 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 @@ -53,7 +53,7 @@ fun ExternalSignerButton(loginViewModel: LoginViewModel) { val resultData = result.data if (result.resultCode == Activity.RESULT_OK && resultData != null) { val loginInfo = ExternalSignerLogin.parseResult(resultData) - if (loginInfo is SignerResult.Successful) { + if (loginInfo is SignerResult.RequestAddressed.Successful) { loginViewModel.updateKey(TextFieldValue(loginInfo.result.pubkey), false) loginViewModel.loginWithExternalSigner(loginInfo.result.packageName) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0ab41e9cf..141ca6b28 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -55,6 +55,13 @@ 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 Zaps 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 8d43e8b21..03b7d5f79 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 @@ -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/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt index 607cf541f..cad8498c3 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/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt index a7e14efd6..678a654fb 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/compose/AsyncCachedState.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 088fd830d..733dfdac2 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/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt index be92b41be..0cbd18b87 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 @@ -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/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 286a6ce0d..9de3c4188 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ReadWrite 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 { @@ -49,206 +49,205 @@ internal class Nip46Test { 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.id, actual.event.id) } - 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 = dummyEvent) + 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(dummyEvent.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 ca39a1354..cd3056d96 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper 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 @@ -89,42 +89,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( + originalNote = poll, + relays = setOf("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 +149,34 @@ 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( + 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, + ) } - } - 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 d37391915..1b244a2b3 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 acf2c7df0..0f14d4abc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz 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 @@ -68,22 +67,23 @@ 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.BlockedRelayListEvent -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.TrustedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +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.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 @@ -124,19 +124,30 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent 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) @@ -265,12 +276,9 @@ class EventFactory { VideoVerticalEvent.KIND -> VideoVerticalEvent(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/decoupling/DecoupledCipher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt index f0f0d5bf8..d41537de4 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 5c5308380..0a812043f 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 50f10e194..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt +++ /dev/null @@ -1,173 +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.experimental.edits.tags.RelayTag -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.JsonMapper -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.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(RelayTag::parse) - .plus( - privateTagsCache?.mapNotNull(RelayTag::parse) ?: 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 = JsonMapper.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 = JsonMapper.mapper.writeValueAsString(privateTags) - - signer.nip44Encrypt( - msg, - signer.pubKey, - onReady, - ) - } - - fun createTagArray(relays: List): Array> = - relays - .map { - RelayTag.assemble(it) - }.toTypedArray() - - fun updateRelayList( - earlierVersion: PrivateOutboxRelayListEvent, - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.privateTagsCache - ?.filter(RelayTag::notMatch) - ?.plus( - relays.map { - RelayTag.assemble(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 f0b09f6fc..39e5a733d 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/RoomId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt index 884b19def..42cc41a3c 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,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat +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 @@ -39,4 +40,6 @@ data class RoomId( result } } + + fun toTagArray() = RoomIdTag.assemble(id, relayUrl) } 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 5ce927a9d..37bbaf1bb 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,15 +22,22 @@ 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.tags.RoomIdTag +import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms 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.remove import com.vitorpamplona.quartz.utils.TimeUtils +import java.lang.reflect.Modifier.isPrivate @Immutable class EphemeralChatListEvent( @@ -41,31 +48,6 @@ class EphemeralChatListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: Set? = null - - 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() - } - companion object { const val KIND = 10023 const val ALT = "Ephemeral Chat List" @@ -73,76 +55,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.assemble(room)) + ): EphemeralChatListEvent = if (isPrivate) { - PrivateTagsInContent.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.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.assemble(room.id, room.relayUrl.url), - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } + ): EphemeralChatListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(room.toTagArray()), + tags = earlierVersion.tags.remove(room.toTagArray()), + 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.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 b0a6c3a55..441cb8d6c 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 000000000..b45a43d50 --- /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/profileGallery/GalleryListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt index a17ed3ef3..a6af5d84e 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/relationshipStatus/RelationshipStatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt index ccfbf6db4..ac22a1e95 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 fb68a96a6..58626823a 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 407024e02..32561ccd6 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/nip01Core/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt index c8a9cc3be..81e828a65 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/crypto/EventAssembler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt index e16be1da7..b6fd97e0f 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/jackson/JsonMapper.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt index 36712c158..b365d765d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorSerializer class JsonMapper { - companion object Companion { + companion object { val defaultPrettyPrinter = InliningTagArrayPrettyPrinter() val mapper = diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt index d80ce6979..d8c51d9c2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt @@ -24,10 +24,13 @@ import android.util.Log 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: IRelayClient) -> Unit, + val scope: CoroutineScope, + val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit, ) { private val clientListener = object : IRelayClientListener { @@ -35,7 +38,9 @@ class RelayAuthenticator( relay: IRelayClient, challenge: String, ) { - authenticate(challenge, relay) + scope.launch { + authenticate(challenge, relay) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 698b81a0b..761ef648f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -144,7 +144,6 @@ open class BasicRelayClient( stats.newError(e.message ?: "Error trying to connect: ${e.javaClass.simpleName}") markConnectionAsClosed() - e.printStackTrace() } finally { connectingMutex.set(false) } @@ -224,7 +223,6 @@ open class BasicRelayClient( markConnectionAsClosed() Log.w(logTag, "OnFailure $code $response ${t.message} $socket") - t.printStackTrace() listener.onError( this@BasicRelayClient, "", 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 c5e3635d0..40dbd3a16 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 @@ -33,104 +31,49 @@ abstract class NostrSigner( ) { abstract fun isWriteable(): Boolean - fun sign( - ev: EventTemplate, - onReady: (T) -> Unit, - ) = sign(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) + suspend fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) - abstract fun sign( + abstract suspend fun sign( createdAt: Long, kind: Int, tags: Array>, content: String, - onReady: (T) -> Unit, - ) + ): T - abstract fun nip04Encrypt( + abstract suspend fun nip04Encrypt( plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip04Decrypt( + abstract suspend fun nip04Decrypt( ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip44Encrypt( + abstract suspend fun nip44Encrypt( plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip44Decrypt( + abstract suspend fun nip44Decrypt( ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) + abstract suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent - abstract fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) + abstract suspend fun deriveKey(nonce: HexKey): HexKey - fun decrypt( + suspend fun decrypt( encryptedContent: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (EncryptedInfo.isNIP04(encryptedContent)) { - nip04Decrypt(encryptedContent, fromPublicKey, onReady) + ): String { + if (encryptedContent.isBlank()) throw SignerExceptions.NothingToDecrypt() + + 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 d441e9193..6c8992da6 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,6 +26,7 @@ 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, @@ -34,59 +35,68 @@ class NostrSignerInternal( override fun isWriteable(): Boolean = keyPair.privKey != null - override fun sign( + inline fun runWrapErrors(action: () -> T): T = + try { + action() + } catch (e: SignerExceptions) { + throw e + } catch (e: Exception) { + if (e is CancellationException) throw e + throw SignerExceptions.CouldNotPerformException("Could not sign event.", 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( + override suspend fun nip04Encrypt( plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip04Encrypt(plaintext, toPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip04Encrypt(plaintext, toPublicKey) + } - override fun nip04Decrypt( + override suspend fun nip04Decrypt( ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip04Decrypt(ciphertext, fromPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip04Decrypt(ciphertext, fromPublicKey) + } - override fun nip44Encrypt( + override suspend fun nip44Encrypt( plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip44Encrypt(plaintext, toPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip44Encrypt(plaintext, toPublicKey) + } - override fun nip44Decrypt( + override suspend fun nip44Decrypt( ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip44Decrypt(ciphertext, 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 51f3f051a..25c9410c3 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,78 @@ 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() 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() 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 000000000..317a60bce --- /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 000000000..cda531884 --- /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 000000000..328a73a53 --- /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/tags/addressables/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt index 22cdf14e3..74a0ac49b 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 @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ATag( val kind: Int, val pubKeyHex: HexKey, - val dTag: String, + val dTag: String = "", val relay: NormalizedRelayUrl? = null, ) { constructor(address: Address, relayHint: NormalizedRelayUrl? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) 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 93b2d48c0..358d53559 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 @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class Address( val kind: Int, val pubKeyHex: HexKey, - val dTag: String, + val dTag: String = "", ) : Comparable
{ fun toValue() = assemble(kind, pubKeyHex, dTag) @@ -59,7 +59,7 @@ data class Address( fun assemble( kind: Int, pubKeyHex: HexKey, - dTag: String, + dTag: String = "", ) = "$kind:$pubKeyHex:$dTag" @JvmStatic 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 25811be7b..4ad0717ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -94,12 +94,12 @@ class ContactListEvent( fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:" - fun createFromScratch( + suspend fun createFromScratch( followUsers: List = emptyList(), relayUse: Map? = emptyMap(), signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): ContactListEvent? { + ): ContactListEvent { val content = relayUse?.let { RelaySet.assemble(it) } ?: "" val tags = @@ -109,13 +109,12 @@ class ContactListEvent( return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } - fun createFromScratch( + suspend fun createFromScratch( followUsers: 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() } @@ -124,53 +123,47 @@ class ContactListEvent( 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 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( @@ -178,17 +171,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 @@ -196,7 +187,7 @@ class ContactListEvent( 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/nip04Dm/PrivateDMCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt new file mode 100644 index 000000000..0ae190332 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.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.nip04Dm + +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 + +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 + } + } + } + + 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 1b140d8a2..68d68a805 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/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 9506c1628..cccbde429 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -48,7 +48,9 @@ class Nip11RelayInformation( ) { 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) } 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 daeb75142..fa116d679 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 807329a8b..3b1ef9ecb 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 @@ -68,6 +69,8 @@ open class BaseDMGroupEvent( return result } + override fun isIncluded(pubKey: HexKey) = 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 188906974..fd45910be 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 865ba93cc..0beca6658 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/settings/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index 40c933834..8c44924c2 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 @@ -54,19 +54,17 @@ class ChatMessageRelayListEvent( fun createTagArray(relays: List): Array> = relays - .map { - RelayTag.assemble(it) - }.plusElement( + .map { RelayTag.assemble(it) } + .plusElement( AltTag.assemble("Relay list to receive private messages"), ).toTypedArray() - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: ChatMessageRelayListEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { + ): ChatMessageRelayListEvent { val tags = earlierVersion.tags .filter(RelayTag::notMatch) @@ -76,31 +74,19 @@ class ChatMessageRelayListEvent( }, ).toTypedArray() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + return signer.sign(createdAt, KIND, tags, earlierVersion.content) } - fun createFromScratch( + suspend fun create( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } + ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") fun create( 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/nip18Reposts/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt index a47bbd275..053321563 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -110,12 +110,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 = @@ -131,7 +130,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/nip19Bech32/entities/NAddress.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt index d16627a6c..3fe413874 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 @@ -56,7 +56,6 @@ data class NAddress( } } catch (e: Throwable) { Log.w("NAddress", "Issue trying to Decode NIP19 $this: ${e.message}") - // e.printStackTrace() } return null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt index 89bb869e8..11bca830e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNote data class NNote( val hex: String, ) : Entity { - companion object Companion { + companion object { fun parse(bytes: ByteArray): NNote? { if (bytes.isEmpty()) return null return NNote(bytes.toHexKey()) 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 2c06d9faa..a3c9a23ed 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/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt index 384b541da..18f3f373e 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,17 +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.hints.types.EventIdHintOptional +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.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -43,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 @@ -69,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: EventIdHintOptional, + 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.remove(channel.toTagArray()), + tags = earlierVersion.tags.remove(channel.toTagArray()), + 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: EventIdHintOptional, - 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 2c026c622..ef99d8827 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 @@ -24,8 +24,11 @@ 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: 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 000000000..b0b929173 --- /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 000000000..88aafdff8 --- /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/nip30CustomEmoji/pack/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index 4fdb1bbc7..4e7f4d045 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/nip31Alts/AltTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt index 66b44623e..910c5a179 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/patch/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt index 367c85bc8..ab12e8be3 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 @@ -107,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( @@ -121,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/nip37Drafts/DraftBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt index 15e9a15c8..9a6befef4 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 7e25c3bb7..b982ca9fd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt @@ -20,7 +20,9 @@ */ 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 @@ -30,6 +32,7 @@ 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 @@ -41,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( @@ -67,76 +69,23 @@ class DraftEvent( override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - @Transient private var cachedInnerEvent: Map = mapOf() - - override fun countMemory(): Long = - super.countMemory() + - 32 + (cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) }) - 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 } } @@ -149,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}")) @@ -274,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 000000000..ef14999b3 --- /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 000000000..05a93432b --- /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 43eb9d1b0..17aed53f0 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/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index 83d8a1797..acd65b79a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -45,36 +45,34 @@ class RelayAuthEvent( companion object { const val KIND = 22242 - fun create( + suspend fun create( relay: NormalizedRelayUrl, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { + ): RelayAuthEvent { val content = "" val tags = arrayOf( RelayTag.assemble(relay), ChallengeTag.assemble(challenge), ) - signer.sign(createdAt, KIND, tags, content, onReady) + return signer.sign(createdAt, KIND, tags, content) } - fun create( + suspend fun create( relays: List, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { + ): RelayAuthEvent { val content = "" val tags = relays .map { RelayTag.assemble(it) } .plusElement(ChallengeTag.assemble(challenge)) .toTypedArray() - signer.sign(createdAt, KIND, tags, content, onReady) + return signer.sign(createdAt, KIND, tags, content) } } } 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 7627fe0e2..43a2b2909 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 @@ -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 { 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 942ecf47c..d7f682472 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 594658ac6..e0ca8c2bd 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/NostrConnectEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt index 231c88a10..cc07e0685 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt @@ -25,10 +25,10 @@ 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.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 = JsonMapper.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 = JsonMapper.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 884f6aa90..919bc33a8 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.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 = JsonMapper.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, - ) { + ): 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 c065a2a27..1f019bac6 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.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) { - JsonMapper.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 5a755f6d6..22c696dac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -63,7 +63,7 @@ class Nip47WalletConnect { val relayUri: String, val secret: HexKey?, ) { - fun normalize(): Nip47WalletConnect.Nip47URINorm? = + fun normalize(): Nip47URINorm? = RelayUrlNormalizer.normalizeOrNull(relayUri)?.let { Nip47URINorm( pubKeyHex, 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 000000000..890fce7ac --- /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 000000000..d4b31fb67 --- /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 8ef77d9f4..6dd52abc3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -21,16 +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.nip50Search.tags.RelayTag +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( @@ -40,66 +51,72 @@ class SearchRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = tags.mapNotNull(RelayTag::parse) +) : 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 { - RelayTag.assemble(it) - }.plusElement(AltTag.assemble("Relay list to use for Search")) - .toTypedArray() - - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: SearchRelayListEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter(RelayTag::notMatch) - .plus( - relays.map { - RelayTag.assemble(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( + suspend fun create( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", 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: 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/nip51Lists/BlockedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BlockedRelayListEvent.kt deleted file mode 100644 index 12c371db2..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BlockedRelayListEvent.kt +++ /dev/null @@ -1,105 +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.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.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip50Search.tags.RelayTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class BlockedRelayListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = tags.mapNotNull(RelayTag::parse) - - companion object { - const val KIND = 10006 - - 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 createTagArray(relays: List): Array> = - relays - .map { - RelayTag.assemble(it) - }.plusElement(AltTag.assemble("Relay list to use for trusted connections")) - .toTypedArray() - - fun updateRelayList( - earlierVersion: BlockedRelayListEvent, - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BlockedRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter(RelayTag::notMatch) - .plus( - relays.map { - RelayTag.assemble(it) - }, - ).toTypedArray() - - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) - } - - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BlockedRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BlockedRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } - - fun create( - relays: List, - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): BlockedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") - } -} 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 ee43e98ec..000000000 --- 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 95dac426c..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt +++ /dev/null @@ -1,188 +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.JsonMapper -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 linkedEventIds() = tags.mapNotNull(ETag::parseId) + (cachedPrivateTags()?.mapNotNull(ETag::parseId) ?: emptyList()) - - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ATag::parseAsHint) ?: emptyList()) - - override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + (cachedPrivateTags()?.mapNotNull(ATag::parseAddressId) ?: emptyList()) - - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(PTag::parseAsHint) ?: emptyList()) - - override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + (cachedPrivateTags()?.mapNotNull(PTag::parseKey) ?: 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 = JsonMapper.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 fc0eb9f1a..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt +++ /dev/null @@ -1,285 +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.nip51Lists.PeopleListEvent.UsersAndWords -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.tryAndWait -import kotlin.coroutines.resume - -@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 publicAndCachedUsersAndWords() = - UsersAndWords( - filterTagList("p", cachedPrivateTags()), - filterTagList("word", cachedPrivateTags()), - ) - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (UsersAndWords) -> Unit, - ) { - privateTagsOrEmpty(signer) { - onReady( - UsersAndWords( - filterTagList("p", it), - filterTagList("word", it), - ), - ) - } - } - - suspend fun publicAndPrivateUsersAndWords(signer: NostrSigner): UsersAndWords? = - tryAndWait { continuation -> - publicAndPrivateUsersAndWords(signer) { privateTagList -> - continuation.resume(privateTagList) - } - } - - 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 762660a8e..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt +++ /dev/null @@ -1,305 +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 -import com.vitorpamplona.quartz.utils.tryAndWait -import kotlin.coroutines.resume - -@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 publicAndCachedPrivateUsersAndWords() = - UsersAndWords( - filterTagList("p", cachedPrivateTags()), - filterTagList("word", cachedPrivateTags()), - ) - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (UsersAndWords) -> Unit, - ) { - privateTagsOrEmpty(signer) { - onReady( - UsersAndWords( - filterTagList("p", it), - filterTagList("word", it), - ), - ) - } - } - - suspend fun publicAndPrivateUsersAndWords(signer: NostrSigner): UsersAndWords? = - tryAndWait { continuation -> - publicAndPrivateUsersAndWords(signer) { privateTagList -> - continuation.resume(privateTagList) - } - } - - 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 0fdc7bd2c..e92935e66 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 6152aa565..453a7b44a 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 fba112dd9..12d70ed5c 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 000000000..e2ac34b1a --- /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/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt deleted file mode 100644 index 4a2111680..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt +++ /dev/null @@ -1,62 +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.BaseAddressableEvent -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.nip51Lists.tags.RelayTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class RelaySetEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = tags.mapNotNull(RelayTag::parse) - - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - - companion object { - const val KIND = 30002 - const val ALT = "Relay list" - - fun create( - relays: List, - signer: NostrSigner, - 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) - } - } -} 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 e36473115..e24563214 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip51Lists import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.utils.startsWith +import com.vitorpamplona.quartz.utils.startsWithAny inline fun TagArray.filterToArray(predicate: (Array) -> Boolean): TagArray = filterTo(ArrayList(), predicate).toTypedArray() @@ -29,6 +30,14 @@ 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.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/TrustedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TrustedRelayListEvent.kt deleted file mode 100644 index 22436fb97..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TrustedRelayListEvent.kt +++ /dev/null @@ -1,105 +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.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.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip50Search.tags.RelayTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class TrustedRelayListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = tags.mapNotNull(RelayTag::parse) - - companion object { - const val KIND = 10089 - - 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 createTagArray(relays: List): Array> = - relays - .map { - RelayTag.assemble(it) - }.plusElement(AltTag.assemble("Relay list to use for trusted connections")) - .toTypedArray() - - fun updateRelayList( - earlierVersion: TrustedRelayListEvent, - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (TrustedRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter(RelayTag::notMatch) - .plus( - relays.map { - RelayTag.assemble(it) - }, - ).toTypedArray() - - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) - } - - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (TrustedRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (TrustedRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } - - fun create( - relays: List, - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): TrustedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") - } -} 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 000000000..2aa6f41dc --- /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 000000000..3fc8ebe46 --- /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 000000000..ede0ece45 --- /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 000000000..a3f344e45 --- /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 000000000..8bfeb5a59 --- /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/nip55AndroidSigner/api/foreground/processors/SignResultProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt similarity index 59% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/SignResultProcessor.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt index dc90d7b7d..7525a1a1d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/SignResultProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt @@ -18,24 +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.quartz.nip55AndroidSigner.api.foreground.processors +package com.vitorpamplona.quartz.nip51Lists.encryption -import android.content.Intent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -class SignResultProcessor( - val unsignedEvent: Event, - val onReady: (Event) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = SignResponse.parse(intent, unsignedEvent) +suspend fun NostrSigner.signNip51List( + createdAt: Long, + kind: Int, + tags: Array>, + privateTags: Array>, +) = sign( + createdAt = createdAt, + kind = kind, + tags = tags, + content = PrivateTagsInContent.encryptNip44(privateTags, this), +) - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.event) - } - } -} +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 123dbb7f7..fec602548 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.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) = JsonMapper.mapper.readValue>>(content) - fun encode(privateTags: Array>) = JsonMapper.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 00fb55b4e..fcecb05db 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 000000000..511395f54 --- /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 000000000..191b6e869 --- /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 000000000..622e2767e --- /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/locations/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt similarity index 88% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/TagArrayBuilderExt.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt index e91af0ce2..b10d92348 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt @@ -18,9 +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.quartz.nip51Lists.locations +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 000000000..efaeb4292 --- /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 000000000..1fc707120 --- /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/interests/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt similarity index 84% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/TagArrayBuilderExt.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt index 4fca44c0e..e6168ce2f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt @@ -18,9 +18,12 @@ * AN ACTION OF CONTRACT, TORT 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.interests +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 000000000..7236e7871 --- /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/interests/HashtagListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/HashtagListEvent.kt deleted file mode 100644 index 3e85f0797..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/HashtagListEvent.kt +++ /dev/null @@ -1,220 +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.interests - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -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.nip51Lists.PrivateTagArrayBuilder -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.tryAndWait -import kotlin.coroutines.resume - -@Immutable -class HashtagListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateHashtagCache: Set? = null - - fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) - - fun publicAndCachedPrivateHashtags() = publicHashtags().toSet() + (publicAndPrivateHashtagCache ?: emptySet()) - - fun publicAndPrivateHashtag( - signer: NostrSigner, - onReady: (Set) -> Unit, - ) { - publicAndPrivateHashtagCache?.let { eventList -> - onReady(eventList) - return - } - - mergeTagList(signer) { - val set = it.mapNotNull(HashtagTag::parse).toSet() - publicAndPrivateHashtagCache = set - onReady(set) - } - } - - suspend fun publicAndPrivateHashtag(signer: NostrSigner): Set? { - publicAndPrivateHashtagCache?.let { return it } - - return tryAndWait { continuation -> - publicAndPrivateHashtag(signer) { privateTagList -> - continuation.resume(privateTagList) - } - } - } - - 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) - - private fun createHashtagBase( - tags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.create( - tags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun createHashtag( - hashtag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) = createHashtagBase( - tags = arrayOf(HashtagTag.assemble(hashtag)), - isPrivate = isPrivate, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - - fun createHashtags( - hashtags: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) = createHashtagBase( - tags = HashtagTag.assemble(hashtags).toTypedArray(), - isPrivate = isPrivate, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - - fun removeHashtag( - earlierVersion: HashtagListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.removeAll( - earlierVersion, - HashtagTag.assemble(hashtag), - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - private fun addHashtagBase( - earlierVersion: HashtagListEvent, - newTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.addAll( - earlierVersion, - newTags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun addHashtag( - earlierVersion: HashtagListEvent, - hashtag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) = addHashtagBase( - earlierVersion, - arrayOf(HashtagTag.assemble(hashtag)), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addHashtags( - earlierVersion: HashtagListEvent, - hashtags: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> Unit, - ) = addHashtagBase( - earlierVersion, - HashtagTag.assemble(hashtags).toTypedArray(), - isPrivate, - signer, - createdAt, - onReady, - ) - - private fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HashtagListEvent) -> 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( - hashtags: List, - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): HashtagListEvent? { - val tags = HashtagTag.assemble(hashtags).toTypedArray() - return signer.sign(createdAt, KIND, tags, "") - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt deleted file mode 100644 index d21b0239f..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt +++ /dev/null @@ -1,220 +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.locations - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -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.nip51Lists.PrivateTagArrayBuilder -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.tryAndWait -import kotlin.coroutines.resume - -@Immutable -class GeohashListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateGeohashCache: Set? = null - - fun publicGeohashes() = tags.mapNotNull(GeoHashTag::parse) - - fun publicAndCachedPrivateGeohash() = publicGeohashes().toSet() + (publicAndPrivateGeohashCache ?: emptySet()) - - fun publicAndPrivateGeohash( - signer: NostrSigner, - onReady: (Set) -> Unit, - ) { - publicAndPrivateGeohashCache?.let { eventList -> - onReady(eventList) - return - } - - mergeTagList(signer) { - val set = it.mapNotNull(GeoHashTag::parse).toSet() - publicAndPrivateGeohashCache = set - onReady(set) - } - } - - suspend fun publicAndPrivateGeohash(signer: NostrSigner): Set? { - publicAndPrivateGeohashCache?.let { return it } - - return tryAndWait { continuation -> - publicAndPrivateGeohash(signer) { privateTagList -> - continuation.resume(privateTagList) - } - } - } - - 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) - - private fun createGeohashBase( - tags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.create( - tags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun createGeohash( - geohash: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) = createGeohashBase( - tags = arrayOf(GeoHashTag.assembleSingle(geohash)), - isPrivate = isPrivate, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - - fun createGeohashs( - geohashs: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) = createGeohashBase( - tags = geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), - isPrivate = isPrivate, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - - fun removeGeohash( - earlierVersion: GeohashListEvent, - geohash: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.removeAll( - earlierVersion, - GeoHashTag.assembleSingle(geohash), - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - private fun addGeohashBase( - earlierVersion: GeohashListEvent, - newTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.addAll( - earlierVersion, - newTags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun addGeohash( - earlierVersion: GeohashListEvent, - geohash: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) = addGeohashBase( - earlierVersion, - arrayOf(GeoHashTag.assembleSingle(geohash)), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addGeohashs( - earlierVersion: GeohashListEvent, - geohashs: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> Unit, - ) = addGeohashBase( - earlierVersion, - geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), - isPrivate, - signer, - createdAt, - onReady, - ) - - private fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GeohashListEvent) -> 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( - geohashs: List, - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): GeohashListEvent? { - val tags = geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray() - return signer.sign(createdAt, KIND, tags, "") - } - } -} 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 000000000..70055d4d9 --- /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 000000000..4d0fdaa2f --- /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 000000000..1b918a68e --- /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 000000000..a6a65786c --- /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 000000000..2558e6d54 --- /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/nip50Search/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt similarity index 54% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/tags/RelayTag.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt index db8babc10..1204b5d42 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt @@ -18,35 +18,57 @@ * AN ACTION OF CONTRACT, TORT 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.tags +package com.vitorpamplona.quartz.nip51Lists.muteList.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.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) -class RelayTag { companion object { - const val TAG_NAME = "relay" + const val TAG_NAME = "word" @JvmStatic - fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() @JvmStatic - fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + fun isTagged( + tag: Array, + word: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == word @JvmStatic - fun parse(tag: Array): NormalizedRelayUrl? { + fun parse(tag: Array): WordTag? { 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 + return WordTag(tag[1]) } @JvmStatic - fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + 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 000000000..a0c69898a --- /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 000000000..2c9053ab2 --- /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 000000000..67c456816 --- /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 000000000..4a6a88c4f --- /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 + 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/TrustedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt new file mode 100644 index 000000000..7fd826246 --- /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/experimental/edits/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/tags/RelayTag.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt index c69057fd1..b7c7261cb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.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.experimental.edits.tags +package com.vitorpamplona.quartz.nip51Lists.relayLists.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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 000000000..b8626bf5f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.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.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.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.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 000000000..35413f65b --- /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 000000000..5866237ee --- /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/TitleTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt index f3ebfc5cb..5687d5fbf 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 4418671c2..6e74d196f 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 72b7e9a15..9bec27678 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 37ff20191..700f7a566 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 9f34f6c1d..2b9b5e2e3 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/streaming/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index 45104fede..23c919fa6 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 @@ -124,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/nip54Wiki/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index cc490826a..f8d65cda9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -133,21 +133,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/api/CommandType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt index 6f738db18..4aadd4a8a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt @@ -33,7 +33,7 @@ enum class CommandType( DERIVE_KEY("derive_key"), ; - companion object Companion { + companion object { fun parse(code: String): CommandType? = when (code) { SIGN_EVENT.code -> SIGN_EVENT 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 index b8018518b..859c52b35 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt @@ -24,38 +24,43 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -interface RequestAddressed +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful( + val result: T, + ) : RequestAddressed -sealed class SignerResult { - class Successful( - val result: T, - ) : SignerResult(), - RequestAddressed + class ManuallyRejected : RequestAddressed - class Rejected : - SignerResult(), - RequestAddressed + class AutomaticallyRejected : RequestAddressed - class ReceivedButCouldNotPerform( - message: String? = null, - ) : SignerResult(), - RequestAddressed + class TimedOut : RequestAddressed - class ReceivedButCouldNotParseEventFromResult( - val eventJson: String, - ) : SignerResult(), - RequestAddressed + class NoActivityToLaunchFrom : RequestAddressed - class ReceivedButCouldNotVerifyResultingEvent( - val invalidEvent: Event, - ) : SignerResult(), - RequestAddressed + class SignerNotFound : RequestAddressed - class ErrorExceptionCallingContentResolver( - val e: Exception? = null, - ) : SignerResult() + class ReceivedButCouldNotPerform( + val message: String? = null, + ) : RequestAddressed - class RequiresManualApproval : SignerResult() + 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 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 index 197c639f8..9e496471e 100644 --- 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 @@ -48,18 +48,18 @@ class DecryptZapQuery( val event = Event.fromJsonOrNull(decryptedEventAsJson) as? LnZapPrivateEvent if (event != null) { if (event.verify()) { - SignerResult.Successful(ZapEventDecryptionResult(event)) + SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) } else { - SignerResult.ReceivedButCouldNotVerifyResultingEvent(event) + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) } } else { - SignerResult.ReceivedButCouldNotParseEventFromResult(decryptedEventAsJson) + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(decryptedEventAsJson) } } else { - SignerResult.ReceivedButCouldNotPerform(decryptedEventAsJson) + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(decryptedEventAsJson) } } else { - SignerResult.ReceivedButCouldNotPerform(decryptedEventAsJson) + 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 index 6d5262163..e2fe9a460 100644 --- 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 @@ -43,9 +43,9 @@ class DeriveKeyQuery( ) { cursor -> val newPrivateKey = cursor.getStringByName("result") if (!newPrivateKey.isNullOrBlank()) { - SignerResult.Successful(DerivationResult(newPrivateKey)) + SignerResult.RequestAddressed.Successful(DerivationResult(newPrivateKey)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 9ab83d968..b86099b2b 100644 --- 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 @@ -45,9 +45,9 @@ class LoginQuery( ) { cursor -> val pubkeyHex = cursor.getStringByName("result") if (!pubkeyHex.isNullOrBlank()) { - SignerResult.Successful(PubKeyResult(pubkeyHex, packageName)) + SignerResult.RequestAddressed.Successful(PubKeyResult(pubkeyHex, packageName)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 35bc8cb1d..ed7b13d08 100644 --- 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 @@ -46,9 +46,9 @@ class Nip04DecryptQuery( ) { cursor -> val plaintext = cursor.getStringByName("result") if (!plaintext.isNullOrBlank()) { - SignerResult.Successful(DecryptionResult(plaintext)) + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 96273ae6e..a318019f7 100644 --- 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 @@ -46,9 +46,9 @@ class Nip04EncryptQuery( ) { cursor -> val ciphertext = cursor.getStringByName("result") if (!ciphertext.isNullOrBlank()) { - SignerResult.Successful(EncryptionResult(ciphertext)) + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 844143b29..4d9288f74 100644 --- 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 @@ -46,9 +46,9 @@ class Nip44DecryptQuery( ) { cursor -> val plaintext = cursor.getStringByName("result") if (!plaintext.isNullOrBlank()) { - SignerResult.Successful(DecryptionResult(plaintext)) + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 37deecc04..6e074344f 100644 --- 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 @@ -46,9 +46,9 @@ class Nip44EncryptQuery( ) { cursor -> val ciphertext = cursor.getStringByName("result") if (!ciphertext.isNullOrBlank()) { - SignerResult.Successful(EncryptionResult(ciphertext)) + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index b665c667f..bf1bc9924 100644 --- 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 @@ -50,20 +50,20 @@ class SignQuery( val event = Event.fromJsonOrNull(eventJson) if (event != null) { if (event.verify()) { - SignerResult.Successful(SignResult(event)) + SignerResult.RequestAddressed.Successful(SignResult(event)) } else { - SignerResult.ReceivedButCouldNotVerifyResultingEvent(event) + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) } } else { - SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson) + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) } } else { - SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson) + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) } } else { val signature = cursor.getStringByName("result") if (!signature.isNullOrBlank()) { - val event = + val event: Event = EventFactory.create( id = unsignedEvent.id, pubKey = unsignedEvent.pubKey, @@ -74,12 +74,12 @@ class SignQuery( sig = signature, ) if (event.verify()) { - SignerResult.Successful(SignResult(event)) + SignerResult.RequestAddressed.Successful(SignResult(event)) } else { - SignerResult.ReceivedButCouldNotVerifyResultingEvent(event) + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) } } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 9dcb25e4e..ae940905b 100644 --- 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 @@ -42,15 +42,15 @@ fun ContentResolver.query( ).use { if (it != null && it.moveToFirst()) { if (it.getColumnIndex("rejected") > -1) { - SignerResult.Rejected() + SignerResult.RequestAddressed.AutomaticallyRejected() } else { map(it) } } else { - SignerResult.RequiresManualApproval() + SignerResult.RequestIncomplete.RequiresManualApproval() } } } catch (e: Exception) { Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e) - SignerResult.ErrorExceptionCallingContentResolver(e) + SignerResult.RequestIncomplete.ErrorExceptionCallingContentResolver(e) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestDatabase.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestDatabase.kt deleted file mode 100644 index ea02137a9..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestDatabase.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.quartz.nip55AndroidSigner.api.foreground - -import android.content.Intent -import androidx.collection.LruCache -import com.vitorpamplona.quartz.utils.RandomInstance - -class IntentRequestDatabase { - private val awaitingRequests = LruCache(2000) - 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 callId = data.getStringExtra("id") - if (callId != null) { - awaitingRequests[callId]?.consume(data) - awaitingRequests.remove(callId) - } - } - - fun launch( - requestIntent: Intent, - responseProcessor: NewResultProcessor, - ) { - appLauncher?.let { - val callId = RandomInstance.randomChars(32) - awaitingRequests.put(callId, responseProcessor) - - requestIntent.putExtra("id", callId) - requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - it.invoke(requestIntent) - } - } -} 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 000000000..8b06b2387 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.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.nip55AndroidSigner.api.foreground + +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlinx.coroutines.CancellationException +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 callId = data.getStringExtra("id") + if (callId != null) { + awaitingRequests[callId]?.resume(data) + awaitingRequests.remove(callId) + } + } + + 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: Intent) -> 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) + + val resultIntent = + tryAndWait(foregroundApprovalTimeout) { continuation -> + continuation.invokeOnCancellation { + awaitingRequests.remove(callId) + } + + awaitingRequests.put(callId, continuation) + + try { + launcher.invoke(requestIntent) + } catch (_: ActivityNotFoundException) { + awaitingRequests.remove(callId) + continuation.resume(activityNotFoundIntent) + } catch (e: Exception) { + awaitingRequests.remove(callId) + continuation.resume(null) + if (e is CancellationException) throw e + } + } + + when (resultIntent) { + null -> SignerResult.RequestAddressed.TimedOut() + activityNotFoundIntent -> SignerResult.RequestAddressed.SignerNotFound() + else -> parser(resultIntent) + } + } ?: SignerResult.RequestAddressed.NoActivityToLaunchFrom() +} 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 index 4c7fa632b..0d602809b 100644 --- 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 @@ -34,21 +34,21 @@ class DecryptZapResponse { return intent } - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val eventJson = intent.getStringExtra("result") return if (!eventJson.isNullOrBlank()) { if (eventJson.startsWith("{")) { val event = Event.fromJsonOrNull(eventJson) as? LnZapPrivateEvent if (event != null) { - SignerResult.Successful(ZapEventDecryptionResult(event)) + SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) } else { - SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson) + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) } } else { - SignerResult.ReceivedButCouldNotPerform() + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() } } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 828837139..8a4a3085c 100644 --- 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 @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult class DeriveKeyResponse { companion object { - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val newPrivateKey = intent.getStringExtra("result") return if (newPrivateKey != null) { - SignerResult.Successful(DerivationResult(newPrivateKey)) + SignerResult.RequestAddressed.Successful(DerivationResult(newPrivateKey)) } else { - SignerResult.ReceivedButCouldNotPerform() + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt index 27c3901e8..1a5cca556 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt @@ -26,19 +26,19 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult class LoginResponse { companion object { - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val pubkey = intent.getStringExtra("result") val packageName = intent.getStringExtra("package") return if (pubkey != null && packageName != null) { - SignerResult.Successful( + SignerResult.RequestAddressed.Successful( PubKeyResult( pubkey = pubkey, packageName = packageName, ), ) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index accae93b2..c7e78b366 100644 --- 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 @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult class Nip04DecryptResponse { companion object { - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val ciphertext = intent.getStringExtra("result") return if (ciphertext != null) { - SignerResult.Successful(DecryptionResult(ciphertext)) + SignerResult.RequestAddressed.Successful(DecryptionResult(ciphertext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index dc7e167d6..d16ea4d7b 100644 --- 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 @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult class Nip04EncryptResponse { companion object { - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val ciphertext = intent.getStringExtra("result") return if (ciphertext != null) { - SignerResult.Successful(EncryptionResult(ciphertext)) + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 9d63a6899..f320afb79 100644 --- 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 @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult class Nip44DecryptResponse { companion object { - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val ciphertext = intent.getStringExtra("result") return if (ciphertext != null) { - SignerResult.Successful(DecryptionResult(ciphertext)) + SignerResult.RequestAddressed.Successful(DecryptionResult(ciphertext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 180a81f6e..c9a9412bd 100644 --- 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 @@ -26,12 +26,12 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult class Nip44EncryptResponse { companion object { - fun parse(intent: Intent): SignerResult { + fun parse(intent: Intent): SignerResult.RequestAddressed { val ciphertext = intent.getStringExtra("result") return if (ciphertext != null) { - SignerResult.Successful(EncryptionResult(ciphertext)) + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) } else { - SignerResult.ReceivedButCouldNotPerform() + 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 index 88a13b8c0..25d040ca4 100644 --- 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 @@ -32,27 +32,27 @@ class SignResponse { fun parse( intent: Intent, unsignedEvent: Event, - ): SignerResult { + ): SignerResult.RequestAddressed { val eventJson = intent.getStringExtra("event") return if (eventJson != null) { if (eventJson.startsWith("{")) { val event = Event.fromJsonOrNull(eventJson) if (event != null) { if (event.verify()) { - SignerResult.Successful(SignResult(event)) + SignerResult.RequestAddressed.Successful(SignResult(event)) } else { - SignerResult.ReceivedButCouldNotVerifyResultingEvent(event) + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) } } else { - SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson) + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) } } else { - SignerResult.ReceivedButCouldNotPerform(eventJson) + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(eventJson) } } else { val signature = intent.getStringExtra("result") if (signature != null && signature.length == 128) { - val event = + val event: Event = EventFactory.create( id = unsignedEvent.id, pubKey = unsignedEvent.pubKey, @@ -63,12 +63,12 @@ class SignResponse { sig = signature, ) if (event.verify()) { - SignerResult.Successful(SignResult(event)) + SignerResult.RequestAddressed.Successful(SignResult(event)) } else { - SignerResult.ReceivedButCouldNotVerifyResultingEvent(event) + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) } } else { - SignerResult.ReceivedButCouldNotPerform(signature) + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(signature) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/DeriveKeyResultProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/DeriveKeyResultProcessor.kt deleted file mode 100644 index 26c436880..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/DeriveKeyResultProcessor.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.quartz.nip55AndroidSigner.api.foreground.processors - -import android.content.Intent -import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DeriveKeyResponse - -class DeriveKeyResultProcessor( - val onReady: (String) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = DeriveKeyResponse.parse(intent) - - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.newPrivKey) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip04DecryptResultProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip04DecryptResultProcessor.kt deleted file mode 100644 index b38fd62dd..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip04DecryptResultProcessor.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.quartz.nip55AndroidSigner.api.foreground.processors - -import android.content.Intent -import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04DecryptResponse - -class Nip04DecryptResultProcessor( - val onReady: (String) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = Nip04DecryptResponse.parse(intent) - - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.plaintext) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip04EncryptResultProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip04EncryptResultProcessor.kt deleted file mode 100644 index afcc12258..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip04EncryptResultProcessor.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.quartz.nip55AndroidSigner.api.foreground.processors - -import android.content.Intent -import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04EncryptResponse - -class Nip04EncryptResultProcessor( - val onReady: (String) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = Nip04EncryptResponse.parse(intent) - - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.ciphertext) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip44DecryptResultProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip44DecryptResultProcessor.kt deleted file mode 100644 index 871ca8b94..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip44DecryptResultProcessor.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.quartz.nip55AndroidSigner.api.foreground.processors - -import android.content.Intent -import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44DecryptResponse - -class Nip44DecryptResultProcessor( - val onReady: (String) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = Nip44DecryptResponse.parse(intent) - - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.plaintext) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip44EncryptResultProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip44EncryptResultProcessor.kt deleted file mode 100644 index eecb92155..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/processors/Nip44EncryptResultProcessor.kt +++ /dev/null @@ -1,38 +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.api.foreground.processors - -import android.content.Intent -import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse - -class Nip44EncryptResultProcessor( - val onReady: (String) -> Unit, -) : NewResultProcessor { - override fun consume(intent: Intent) { - val foregroundResult = Nip44EncryptResponse.parse(intent) - if (foregroundResult is SignerResult.Successful) { - onReady(foregroundResult.result.ciphertext) - } - } -} 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 index af3f02ade..5ac3350a8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt @@ -28,4 +28,6 @@ interface IActivityLauncher { fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) fun newResponse(data: Intent) + + fun hasForegroundActivity(): Boolean } 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 index f1ffb4fbd..f6d23f26d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -26,10 +26,18 @@ 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, @@ -54,13 +62,14 @@ class NostrSignerExternal( this.foregroundQuery.launcher.newResponse(data) } - override fun sign( + override fun hasForegroundActivity() = this.foregroundQuery.launcher.hasForegroundActivity() + + override suspend fun sign( createdAt: Long, kind: Int, tags: Array>, content: String, - onReady: (T) -> Unit, - ) { + ): T { val unsignedEvent = Event( id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), @@ -72,70 +81,110 @@ class NostrSignerExternal( sig = "", ) - val newOnReady: (Event) -> Unit = { result -> - (result as? T)?.let(onReady) + val result = backgroundQuery.sign(unsignedEvent) ?: foregroundQuery.sign(unsignedEvent) + + if (result is SignerResult.RequestAddressed.Successful) { + (result.result.event as? T)?.let { + return it + } } - if (!backgroundQuery.sign(unsignedEvent, newOnReady)) { - foregroundQuery.sign(unsignedEvent, newOnReady) - } + throw convertExceptions("Could not sign", result) } - override fun nip04Encrypt( + override suspend fun nip04Encrypt( plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (!backgroundQuery.nip04Encrypt(plaintext, toPublicKey, onReady)) { - foregroundQuery.nip04Encrypt(plaintext, toPublicKey, onReady) + ): String { + 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 fun nip04Decrypt( + override suspend fun nip04Decrypt( ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (!backgroundQuery.nip04Decrypt(ciphertext, fromPublicKey, onReady)) { - foregroundQuery.nip04Decrypt(ciphertext, fromPublicKey, onReady) + ): 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 fun nip44Encrypt( + override suspend fun nip44Encrypt( plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (!backgroundQuery.nip44Encrypt(plaintext, toPublicKey, onReady)) { - foregroundQuery.nip44Encrypt(plaintext, toPublicKey, onReady) + ): String { + 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 fun nip44Decrypt( + override suspend fun nip44Decrypt( ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (!backgroundQuery.nip44Decrypt(ciphertext, fromPublicKey, onReady)) { - foregroundQuery.nip44Decrypt(ciphertext, fromPublicKey, onReady) + ): 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 fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) { - if (!backgroundQuery.deriveKey(nonce, onReady)) { - foregroundQuery.deriveKey(nonce, onReady) + 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 fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - if (!backgroundQuery.decryptZapEvent(event, onReady)) { - foregroundQuery.decryptZapEvent(event, onReady) + 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 index 9edea8f54..fcf072db5 100644 --- 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 @@ -27,7 +27,6 @@ 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.RequestAddressed import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult @@ -39,7 +38,6 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04E 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.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent class BackgroundRequestHandler( @@ -56,108 +54,31 @@ class BackgroundRequestHandler( val decryptZap = DecryptZapQuery(loggedInUser, packageName, contentResolver) val deriveKey = DeriveKeyQuery(loggedInUser, packageName, contentResolver) - fun login(onReady: (HexKey) -> Unit): Boolean { - val backgroundResult = login.query() + fun login() = login.query() as? SignerResult.RequestAddressed - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.pubkey) - } - - return backgroundResult is RequestAddressed - } - - fun sign( - unsignedEvent: Event, - onReady: (Event) -> Unit, - ): Boolean { - val backgroundResult = sign.query(unsignedEvent) - - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.event) - } - - return backgroundResult is RequestAddressed - } + fun sign(unsignedEvent: Event) = sign.query(unsignedEvent) as? SignerResult.RequestAddressed fun nip04Encrypt( plaintext: String, toPubKey: HexKey, - onReady: (String) -> Unit, - ): Boolean { - val backgroundResult = nip04Encrypt.query(plaintext, toPubKey) - - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.ciphertext) - } - - return backgroundResult is RequestAddressed - } + ) = nip04Encrypt.query(plaintext, toPubKey) as? SignerResult.RequestAddressed fun nip04Decrypt( ciphertext: String, fromPubKey: HexKey, - onReady: (String) -> Unit, - ): Boolean { - val backgroundResult = nip04Decrypt.query(ciphertext, fromPubKey) - - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.plaintext) - } - - return backgroundResult is RequestAddressed - } + ) = nip04Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed fun nip44Encrypt( plaintext: String, toPubKey: HexKey, - onReady: (String) -> Unit, - ): Boolean { - val backgroundResult = nip44Encrypt.query(plaintext, toPubKey) - - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.ciphertext) - } - - return backgroundResult is RequestAddressed - } + ) = nip44Encrypt.query(plaintext, toPubKey) as? SignerResult.RequestAddressed fun nip44Decrypt( ciphertext: String, fromPubKey: HexKey, - onReady: (String) -> Unit, - ): Boolean { - val backgroundResult = nip44Decrypt.query(ciphertext, fromPubKey) + ) = nip44Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.plaintext) - } + fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed - return backgroundResult is RequestAddressed - } - - fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ): Boolean { - val backgroundResult = decryptZap.query(event) - - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.privateEvent) - } - - return backgroundResult is RequestAddressed - } - - fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ): Boolean { - val backgroundResult = deriveKey.query(nonce) - - if (backgroundResult is SignerResult.Successful) { - onReady(backgroundResult.result.newPrivKey) - } - - return backgroundResult is 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 index 27b1584d2..c86f9ebcf 100644 --- 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 @@ -22,7 +22,7 @@ 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.IntentRequestDatabase +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 @@ -30,79 +30,69 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.reques 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.processors.DecryptZapResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.DeriveKeyResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip04DecryptResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip04EncryptResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip44DecryptResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip44EncryptResultProcessor -import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.SignResultProcessor -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +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 = IntentRequestDatabase() + val launcher = IntentRequestManager(foregroundApprovalTimeout) - fun sign( - unsignedEvent: Event, - onReady: (Event) -> Unit, - ) = launcher.launch( - SignRequest.assemble(unsignedEvent, loggedInUser, packageName), - SignResultProcessor(unsignedEvent, onReady), - ) + suspend fun sign(unsignedEvent: Event) = + launcher.launchWaitAndParse( + requestIntentBuilder = { SignRequest.assemble(unsignedEvent, loggedInUser, packageName) }, + parser = { intent -> SignResponse.parse(intent, unsignedEvent) }, + ) - fun nip04Encrypt( + suspend fun nip04Encrypt( plaintext: String, toPubKey: HexKey, - onReady: (String) -> Unit, - ) = launcher.launch( - Nip04EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName), - Nip04EncryptResultProcessor(onReady), + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip04EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName) }, + parser = Nip04EncryptResponse::parse, ) - fun nip04Decrypt( + suspend fun nip04Decrypt( ciphertext: String, fromPubKey: HexKey, - onReady: (String) -> Unit, - ) = launcher.launch( - Nip04DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName), - Nip04DecryptResultProcessor(onReady), + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip04DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName) }, + parser = Nip04DecryptResponse::parse, ) - fun nip44Encrypt( + suspend fun nip44Encrypt( plaintext: String, toPubKey: HexKey, - onReady: (String) -> Unit, - ) = launcher.launch( - Nip44EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName), - Nip44EncryptResultProcessor(onReady), + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip44EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName) }, + parser = Nip44EncryptResponse::parse, ) - fun nip44Decrypt( + suspend fun nip44Decrypt( ciphertext: String, fromPubKey: HexKey, - onReady: (String) -> Unit, - ) = launcher.launch( - Nip44DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName), - Nip44DecryptResultProcessor(onReady), + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip44DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName) }, + parser = Nip44DecryptResponse::parse, ) - fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) = launcher.launch( - DecryptZapRequest.assemble(event, loggedInUser, packageName), - DecryptZapResultProcessor(onReady), - ) + suspend fun decryptZapEvent(event: LnZapRequestEvent) = + launcher.launchWaitAndParse( + requestIntentBuilder = { DecryptZapRequest.assemble(event, loggedInUser, packageName) }, + parser = DecryptZapResponse::parse, + ) - fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) = launcher.launch( - DeriveKeyRequest.assemble(nonce, loggedInUser, packageName), - DeriveKeyResultProcessor(onReady), - ) + 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 099ea723a..9b6f5a363 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/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt index efb16f01c..f2901533e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt @@ -61,21 +61,18 @@ class LnZapPrivateEvent( 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 f2e00897e..48c937658 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -25,7 +25,6 @@ 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 @@ -38,7 +37,6 @@ 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( @@ -64,55 +62,23 @@ class LnZapRequestEvent( override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - // TODO: Create a per key map with resulting options to account for rejections and avoiding reasking. - @Transient - private var privateZapEvent: LnZapPrivateEvent? = null - - override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (privateZapEvent?.countMemory() ?: 0) - fun zappedPost() = tags.mapNotNull(ETag::parseId) fun zappedAuthor() = tags.mapNotNull(PTag::parseKey) 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: (LnZapPrivateEvent) -> Unit, - ) { - privateZapEvent?.let { - onReady(it) - return - } - - signer.decryptZapEvent(this) { - // caches it - privateZapEvent = it - onReady(it) + } else { + throw IllegalStateException("This is not a private zap.") } } @@ -120,7 +86,7 @@ class LnZapRequestEvent( const val KIND = 9734 const val ALT = "Zap request" - fun create( + suspend fun create( originalNote: Event, relays: Set, signer: NostrSigner, @@ -129,10 +95,7 @@ class LnZapRequestEvent( 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), @@ -147,42 +110,45 @@ class LnZapRequestEvent( 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, 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.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 000000000..9e8ede206 --- /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 c0aca3c03..332f8031c 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 IllegalArgumentException("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 IllegalArgumentException("This private zap cannot be decrypted by this key.") } } else { - null + throw IllegalArgumentException("Recipient pubkey not found.") } } catch (e: Exception) { - Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) - null + throw IllegalArgumentException("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/nip59Giftwrap/rumors/RumorAssembler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt new file mode 100644 index 000000000..65e7a61fc --- /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 e580f2362..c65005c19 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 73cc4ec43..9b8fd9313 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/nip65RelayList/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt index 8b1078e66..315f406b3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -59,47 +59,42 @@ class AdvertisedRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun replaceRelayListWith( + suspend fun replaceRelayListWith( earlierVersion: AdvertisedRelayListEvent, newRelays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { + ): AdvertisedRelayListEvent { val tags = earlierVersion.tags.filter(AdvertisedRelayInfo::notMatch) val relayTags = newRelays.map { it.toTagArray() } - signer.sign(createdAt, KIND, (tags + relayTags).toTypedArray(), earlierVersion.content, onReady) + return signer.sign(createdAt, KIND, (tags + relayTags).toTypedArray(), earlierVersion.content) } - fun createFromScratch( + suspend fun createFromScratch( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) = create(relays, signer, createdAt, onReady) + ) = create(relays, signer, createdAt) - fun create( + suspend fun create( list: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { + ): AdvertisedRelayListEvent { val tags = list.map { it.toTagArray() }.toTypedArray() - val msg = "" - signer.sign(createdAt, KIND, tags, msg, onReady) + return signer.sign(createdAt, KIND, tags, "") } fun create( list: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): AdvertisedRelayListEvent? { + ): AdvertisedRelayListEvent { val tags = list.map { it.toTagArray() }.toTypedArray() - val msg = "" - return signer.sign(createdAt, KIND, tags, msg) + return signer.sign(createdAt, KIND, tags, "") } } } 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 index 747bc9f4d..aaaaab456 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt @@ -22,19 +22,23 @@ package com.vitorpamplona.quartz.nip72ModCommunities.follow 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.AddressHint +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.NostrSignerSync -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +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.GeneralListEvent -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +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 com.vitorpamplona.quartz.utils.tryAndWait -import kotlin.coroutines.resume +import kotlin.collections.plus @Immutable class CommunityListEvent( @@ -44,40 +48,15 @@ class CommunityListEvent( tags: Array>, content: String, sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateAddressCache: Set? = null +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(CommunityTag::parseAsHint) - fun publicCommunities() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(CommunityTag::parseAddressId) - fun publicCommunityIds() = tags.mapNotNull(ATag::parseAddressId) + fun publicCommunities() = tags.communities() - fun publicAndCachedPrivateCommunityIds() = publicCommunityIds() + (publicAndPrivateAddressCache?.map { it.addressId } ?: emptyList()) - - fun publicAndPrivateCommunities( - signer: NostrSigner, - onReady: (Set) -> Unit, - ) { - publicAndPrivateAddressCache?.let { eventList -> - onReady(eventList) - return - } - - mergeTagList(signer) { - val set = it.mapNotNull(ATag::parseAsHint).toSet() - publicAndPrivateAddressCache = set - onReady(set) - } - } - - suspend fun publicAndPrivateCommunities(signer: NostrSigner): Set? { - publicAndPrivateAddressCache?.let { return it } - - return tryAndWait { continuation -> - publicAndPrivateCommunities(signer) { privateTagList -> - continuation.resume(privateTagList) - } - } - } + fun publicCommunityIds() = tags.communityIds() companion object { const val KIND = 10004 @@ -86,186 +65,166 @@ class CommunityListEvent( fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) - private fun createCommunityBase( - tags: Array>, + suspend fun create( + communities: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.create( - tags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) + ): 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, + ) } - fun createCommunity( - community: EventHintBundle, - isPrivate: Boolean, + suspend fun resign( + tags: TagArray, + privateTags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = createCommunityBase( - tags = arrayOf(ATag.assemble(community.event.address(), community.relay)), - isPrivate = isPrivate, + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, signer = signer, createdAt = createdAt, - onReady = onReady, ) - fun createCommunity( - community: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = createCommunityBase( - tags = arrayOf(community.toATagArray()), - isPrivate = isPrivate, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - - fun createCommunities( - communities: List>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = createCommunityBase( - tags = communities.map { ATag.assemble(it.event.address().toValue(), it.relay) }.toTypedArray(), - isPrivate = isPrivate, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - - fun removeCommunity( - earlierVersion: CommunityListEvent, - community: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.removeAll( - earlierVersion, - ATag.assemble(community, null), - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - private fun addCommunityBase( - earlierVersion: CommunityListEvent, - newTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - PrivateTagArrayBuilder.addAll( - earlierVersion, - newTags, - isPrivate, - signer, - ) { encryptedContent, newTags -> - create(encryptedContent, newTags, signer, createdAt, onReady) - } - } - - fun addCommunity( - earlierVersion: CommunityListEvent, - community: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addCommunityBase( - earlierVersion, - arrayOf(community.toATagArray()), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addCommunity( - earlierVersion: CommunityListEvent, - community: EventHintBundle, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addCommunityBase( - earlierVersion, - arrayOf(community.toATag().toATagArray()), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addCommunity( - earlierVersion: CommunityListEvent, - community: AddressHint, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addCommunityBase( - earlierVersion, - arrayOf(ATag.assemble(community.addressId, community.relay)), - isPrivate, - signer, - createdAt, - onReady, - ) - - fun addCommunities( - earlierVersion: CommunityListEvent, - communities: List>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addCommunityBase( - earlierVersion, - communities.map { it.toATag().toATagArray() }.toTypedArray(), - isPrivate, - signer, - createdAt, - onReady, - ) - - private fun create( + suspend fun resign( content: String, - tags: Array>, + tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { + ): CommunityListEvent { 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) } - fun create( - list: List, - signer: NostrSignerSync, + suspend fun create( + publicCommunities: List = emptyList(), + privateCommunities: List = emptyList(), + signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): CommunityListEvent? { - val tags = list.map { ATag.assemble(it.addressId, it.relay) }.toTypedArray() - return signer.sign(createdAt, KIND, tags, "") + ): 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 index 098657d26..af274d9ef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt @@ -24,6 +24,12 @@ 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, 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 000000000..ca2b50b67 --- /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 000000000..7b6bcc6d3 --- /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/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index c3725b465..6dcd9a31e 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 3daf22705..5e8254e91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -130,13 +130,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/definition/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index 7c2236203..f2d77522e 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 @@ -66,8 +67,8 @@ class AppDefinitionEvent( 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/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt index c31dd4a47..addd3c39c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt @@ -43,14 +43,13 @@ class NIP90ContentDiscoveryRequestEvent( const val KIND = 5300 const val ALT = "NIP90 Content Discovery request" - fun create( + suspend fun create( dvmPublicKey: HexKey, forUser: HexKey, relays: Set, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, - ) { + ): NIP90ContentDiscoveryRequestEvent { val content = "" val tags = mutableListOf>() tags.add(arrayOf("p", dvmPublicKey)) @@ -58,7 +57,7 @@ class NIP90ContentDiscoveryRequestEvent( 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 da692ad58..d7ca0d2ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey 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 @@ -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 950aecf24..a36411df7 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 414c92974..6d4cf85e5 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 c4d997834..a3e619c75 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/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index d2db68483..ca00e9499 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -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/nipB7Blossom/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt index 817ed20d2..9e2c12570 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt @@ -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 271175ddc..db36dd7ce 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/LibSodiumInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt index b8700c606..6c327f423 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/ParallelUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt index 4e32f16a4..b47f4250b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.utils +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -28,7 +29,6 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.selects.select import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.Continuation import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume @@ -66,7 +66,7 @@ suspend fun launchAndWaitAll( */ suspend inline fun tryAndWait( timeoutMillis: Long = 10000, - crossinline asyncFunc: (Continuation) -> Unit, + crossinline asyncFunc: (CancellableContinuation) -> Unit, ): T? = withTimeoutOrNull(timeoutMillis) { suspendCancellableCoroutine { continuation -> @@ -156,6 +156,36 @@ suspend fun mapNotNullAsync( } } +@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. 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 13fc92809..45d83bfc9 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